diff --git a/.gitattributes b/.gitattributes index 84c7add0..14c3c359 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4689c4da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000..e55b4781 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/AccessMap.php b/AccessMap.php index c01e3f33..4913e461 100644 --- a/AccessMap.php +++ b/AccessMap.php @@ -22,21 +22,18 @@ */ class AccessMap implements AccessMapInterface { - private $map = []; + private array $map = []; /** * @param array $attributes An array of attributes to pass to the access decision manager (like roles) * @param string|null $channel The channel to enforce (http, https, or null) */ - public function add(RequestMatcherInterface $requestMatcher, array $attributes = [], ?string $channel = null) + public function add(RequestMatcherInterface $requestMatcher, array $attributes = [], ?string $channel = null): void { $this->map[] = [$requestMatcher, $attributes, $channel]; } - /** - * {@inheritdoc} - */ - public function getPatterns(Request $request) + public function getPatterns(Request $request): array { foreach ($this->map as $elements) { if (null === $elements[0] || $elements[0]->matches($request)) { diff --git a/AccessMapInterface.php b/AccessMapInterface.php index 70a11341..7002c6bd 100644 --- a/AccessMapInterface.php +++ b/AccessMapInterface.php @@ -27,5 +27,5 @@ interface AccessMapInterface * * @return array{0: array|null, 1: string|null} A tuple of security attributes and the required channel */ - public function getPatterns(Request $request); + public function getPatterns(Request $request): array; } diff --git a/AccessToken/AccessTokenExtractorInterface.php b/AccessToken/AccessTokenExtractorInterface.php new file mode 100644 index 00000000..dcd48a3c --- /dev/null +++ b/AccessToken/AccessTokenExtractorInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * The token extractor retrieves the token from a request. + * + * @author Florent Morselli + */ +interface AccessTokenExtractorInterface +{ + public function extractAccessToken(Request $request): ?string; +} diff --git a/AccessToken/AccessTokenHandlerInterface.php b/AccessToken/AccessTokenHandlerInterface.php new file mode 100644 index 00000000..5cbc857a --- /dev/null +++ b/AccessToken/AccessTokenHandlerInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +/** + * The token handler retrieves the user identifier from the token. + * In order to get the user identifier, implementations may need to load and validate the token (e.g. revocation, expiration time, digital signature...). + * + * @author Florent Morselli + */ +interface AccessTokenHandlerInterface +{ + /** + * @throws AuthenticationException + */ + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge; +} diff --git a/AccessToken/Cas/Cas2Handler.php b/AccessToken/Cas/Cas2Handler.php new file mode 100644 index 00000000..61bcdbeb --- /dev/null +++ b/AccessToken/Cas/Cas2Handler.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Cas; + +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @see https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-V2-Specification.html + * + * @author Nicolas Attard + */ +final class Cas2Handler implements AccessTokenHandlerInterface +{ + public function __construct( + private readonly RequestStack $requestStack, + private readonly string $validationUrl, + private readonly string $prefix = 'cas', + private ?HttpClientInterface $client = null, + ) { + if (null === $client) { + if (!class_exists(HttpClient::class)) { + throw new \LogicException(\sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); + } + + $this->client = HttpClient::create(); + } + } + + /** + * @throws AuthenticationException + */ + public function getUserBadgeFrom(string $accessToken): UserBadge + { + $response = $this->client->request('GET', $this->getValidationUrl($accessToken)); + + $xml = new \SimpleXMLElement($response->getContent(), 0, false, $this->prefix, true); + + if (isset($xml->authenticationSuccess)) { + return new UserBadge((string) $xml->authenticationSuccess->user); + } + + if (isset($xml->authenticationFailure)) { + throw new AuthenticationException('CAS Authentication Failure: '.trim((string) $xml->authenticationFailure)); + } + + throw new AuthenticationException('Invalid CAS response.'); + } + + private function getValidationUrl(string $accessToken): string + { + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new \LogicException('Request should exist so it can be processed for error.'); + } + + $query = $request->query->all(); + + if (!isset($query['ticket'])) { + throw new AuthenticationException('No ticket found in request.'); + } + unset($query['ticket']); + $queryString = $query ? '?'.http_build_query($query) : ''; + + return \sprintf('%s?ticket=%s&service=%s', + $this->validationUrl, + urlencode($accessToken), + urlencode($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$queryString) + ); + } +} diff --git a/AccessToken/ChainAccessTokenExtractor.php b/AccessToken/ChainAccessTokenExtractor.php new file mode 100644 index 00000000..ff16b911 --- /dev/null +++ b/AccessToken/ChainAccessTokenExtractor.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * The token extractor retrieves the token from a request. + * + * @author Florent Morselli + */ +final class ChainAccessTokenExtractor implements AccessTokenExtractorInterface +{ + /** + * @param AccessTokenExtractorInterface[] $accessTokenExtractors + */ + public function __construct( + private readonly iterable $accessTokenExtractors, + ) { + } + + public function extractAccessToken(Request $request): ?string + { + foreach ($this->accessTokenExtractors as $extractor) { + if ($accessToken = $extractor->extractAccessToken($request)) { + return $accessToken; + } + } + + return null; + } +} diff --git a/AccessToken/FormEncodedBodyExtractor.php b/AccessToken/FormEncodedBodyExtractor.php new file mode 100644 index 00000000..6661e381 --- /dev/null +++ b/AccessToken/FormEncodedBodyExtractor.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts a token from the body request. + * + * WARNING! + * Because of the security weaknesses associated with this method, + * the request body method SHOULD NOT be used except in application contexts + * where participating browsers do not have access to the "Authorization" request header field. + * + * @author Florent Morselli + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.2 + */ +final class FormEncodedBodyExtractor implements AccessTokenExtractorInterface +{ + public function __construct( + private readonly string $parameter = 'access_token', + ) { + } + + public function extractAccessToken(Request $request): ?string + { + if ( + Request::METHOD_POST !== $request->getMethod() + || !str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded') + ) { + return null; + } + $parameter = $request->request->get($this->parameter); + + return \is_string($parameter) ? $parameter : null; + } +} diff --git a/AccessToken/HeaderAccessTokenExtractor.php b/AccessToken/HeaderAccessTokenExtractor.php new file mode 100644 index 00000000..a03895c0 --- /dev/null +++ b/AccessToken/HeaderAccessTokenExtractor.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts a token from the request header. + * + * @author Florent Morselli + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.1 + */ +final class HeaderAccessTokenExtractor implements AccessTokenExtractorInterface +{ + private string $regex; + + public function __construct( + private readonly string $headerParameter = 'Authorization', + private readonly string $tokenType = 'Bearer', + ) { + $this->regex = \sprintf( + '/^%s([a-zA-Z0-9\-_\+~\/\.]+=*)$/', + '' === $this->tokenType ? '' : preg_quote($this->tokenType).'\s+' + ); + } + + public function extractAccessToken(Request $request): ?string + { + if (!$request->headers->has($this->headerParameter) || !\is_string($header = $request->headers->get($this->headerParameter))) { + return null; + } + + if (preg_match($this->regex, $header, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/AccessToken/OAuth2/Oauth2TokenHandler.php b/AccessToken/OAuth2/Oauth2TokenHandler.php new file mode 100644 index 00000000..05bda02f --- /dev/null +++ b/AccessToken/OAuth2/Oauth2TokenHandler.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\OAuth2; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\OAuth2User; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +use function Symfony\Component\String\u; + +/** + * The token handler validates the token on the authorization server and the Introspection Endpoint. + * + * @see https://tools.ietf.org/html/rfc7662 + * + * @internal + */ +final class Oauth2TokenHandler implements AccessTokenHandlerInterface +{ + public function __construct( + private readonly HttpClientInterface $client, + private readonly ?LoggerInterface $logger = null, + ) { + } + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + try { + // Call the Authorization server to retrieve the resource owner details + // If the token is invalid or expired, the Authorization server will return an error + $claims = $this->client->request('POST', '', [ + 'body' => [ + 'token' => $accessToken, + 'token_type_hint' => 'access_token', + ], + ])->toArray(); + + $sub = $claims['sub'] ?? null; + $username = $claims['username'] ?? null; + if (!$sub && !$username) { + throw new BadCredentialsException('"sub" and "username" claims not found on the authorization server response. At least one is required.'); + } + $active = $claims['active'] ?? false; + if (!$active) { + throw new BadCredentialsException('The claim "active" was not found on the authorization server response or is set to false.'); + } + + return new UserBadge($sub ?? $username, fn () => $this->createUser($claims), $claims); + } catch (AuthenticationException $e) { + $this->logger?->error('An error occurred on the authorization server.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } + + private function createUser(array $claims): OAuth2User + { + if (!\function_exists(\Symfony\Component\String\u::class)) { + throw new \LogicException('You cannot use the "OAuth2TokenHandler" since the String component is not installed. Try running "composer require symfony/string".'); + } + + foreach ($claims as $claim => $value) { + unset($claims[$claim]); + if ('' === $value || null === $value) { + continue; + } + $claims[u($claim)->camel()->toString()] = $value; + } + + if ('' !== ($claims['updatedAt'] ?? '')) { + $claims['updatedAt'] = (new \DateTimeImmutable())->setTimestamp($claims['updatedAt']); + } + + if ('' !== ($claims['emailVerified'] ?? '')) { + $claims['emailVerified'] = (bool) $claims['emailVerified']; + } + + if ('' !== ($claims['phoneNumberVerified'] ?? '')) { + $claims['phoneNumberVerified'] = (bool) $claims['phoneNumberVerified']; + } + + return new OAuth2User(...$claims); + } +} diff --git a/AccessToken/Oidc/Exception/InvalidSignatureException.php b/AccessToken/Oidc/Exception/InvalidSignatureException.php new file mode 100644 index 00000000..56f362ed --- /dev/null +++ b/AccessToken/Oidc/Exception/InvalidSignatureException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc\Exception; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * This exception is thrown when the token signature is invalid. + */ +class InvalidSignatureException extends AuthenticationException +{ + public function getMessageKey(): string + { + return 'Invalid token signature.'; + } +} diff --git a/AccessToken/Oidc/Exception/MissingClaimException.php b/AccessToken/Oidc/Exception/MissingClaimException.php new file mode 100644 index 00000000..e178f2b4 --- /dev/null +++ b/AccessToken/Oidc/Exception/MissingClaimException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc\Exception; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * This exception is thrown when the user is invalid on the OIDC server (e.g.: "email" property is not in the scope). + */ +class MissingClaimException extends AuthenticationException +{ + public function getMessageKey(): string + { + return 'Missing claim.'; + } +} diff --git a/AccessToken/Oidc/OidcTokenHandler.php b/AccessToken/Oidc/OidcTokenHandler.php new file mode 100644 index 00000000..393ca96c --- /dev/null +++ b/AccessToken/Oidc/OidcTokenHandler.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Jose\Component\Checker; +use Jose\Component\Checker\ClaimCheckerManager; +use Jose\Component\Core\Algorithm; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWK; +use Jose\Component\Core\JWKSet; +use Jose\Component\Encryption\JWEDecrypter; +use Jose\Component\Encryption\JWETokenSupport; +use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer; +use Jose\Component\Encryption\Serializer\JWESerializerManager; +use Jose\Component\Signature\JWSTokenSupport; +use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer; +use Jose\Component\Signature\Serializer\JWSSerializerManager; +use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Clock\Clock; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\InvalidSignatureException; +use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * The token handler decodes and validates the token, and retrieves the user identifier from it. + */ +final class OidcTokenHandler implements AccessTokenHandlerInterface +{ + use OidcTrait; + private ?JWKSet $decryptionKeyset = null; + private ?AlgorithmManager $decryptionAlgorithms = null; + private bool $enforceEncryption = false; + + private ?CacheInterface $discoveryCache = null; + private ?HttpClientInterface $discoveryClient = null; + private ?string $oidcConfigurationCacheKey = null; + private ?string $oidcJWKSetCacheKey = null; + + public function __construct( + private Algorithm|AlgorithmManager $signatureAlgorithm, + private JWK|JWKSet|null $signatureKeyset, + private string $audience, + private array $issuers, + private string $claim = 'sub', + private ?LoggerInterface $logger = null, + private ClockInterface $clock = new Clock(), + ) { + if ($signatureAlgorithm instanceof Algorithm) { + trigger_deprecation('symfony/security-http', '7.1', 'First argument must be instance of %s, %s given.', AlgorithmManager::class, Algorithm::class); + $this->signatureAlgorithm = new AlgorithmManager([$signatureAlgorithm]); + } + if ($signatureKeyset instanceof JWK) { + trigger_deprecation('symfony/security-http', '7.1', 'Second argument must be instance of %s, %s given.', JWKSet::class, JWK::class); + $this->signatureKeyset = new JWKSet([$signatureKeyset]); + } + } + + public function enableJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $decryptionAlgorithms, bool $enforceEncryption): void + { + $this->decryptionKeyset = $decryptionKeyset; + $this->decryptionAlgorithms = $decryptionAlgorithms; + $this->enforceEncryption = $enforceEncryption; + } + + public function enableDiscovery(CacheInterface $cache, HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void + { + $this->discoveryCache = $cache; + $this->discoveryClient = $client; + $this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey; + $this->oidcJWKSetCacheKey = $oidcJWKSetCacheKey; + } + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) { + throw new \LogicException('You cannot use the "oidc" token handler since "web-token/jwt-signature" and "web-token/jwt-checker" are not installed. Try running "composer require web-token/jwt-signature web-token/jwt-checker".'); + } + + if (!$this->discoveryCache && !$this->signatureKeyset) { + throw new \LogicException('You cannot use the "oidc" token handler without JWKSet nor "discovery". Please configure JWKSet in the constructor, or call "enableDiscovery" method.'); + } + + $jwkset = $this->signatureKeyset; + if ($this->discoveryCache) { + try { + $oidcConfiguration = json_decode($this->discoveryCache->get($this->oidcConfigurationCacheKey, function (): string { + $response = $this->discoveryClient->request('GET', '.well-known/openid-configuration'); + + return $response->getContent(); + }), true, 512, \JSON_THROW_ON_ERROR); + } catch (\Throwable $e) { + $this->logger?->error('An error occurred while requesting OIDC configuration.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + + try { + $jwkset = JWKSet::createFromJson( + $this->discoveryCache->get($this->oidcJWKSetCacheKey, function () use ($oidcConfiguration): string { + $response = $this->discoveryClient->request('GET', $oidcConfiguration['jwks_uri']); + // we only need signature key + $keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']); + + return json_encode(['keys' => $keys]); + }) + ); + } catch (\Throwable $e) { + $this->logger?->error('An error occurred while requesting OIDC certs.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } + + try { + $accessToken = $this->decryptIfNeeded($accessToken); + $claims = $this->loadAndVerifyJws($accessToken, $jwkset); + $this->verifyClaims($claims); + + if (empty($claims[$this->claim])) { + throw new MissingClaimException(\sprintf('"%s" claim not found.', $this->claim)); + } + + // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate + return new UserBadge($claims[$this->claim], new FallbackUserLoader(function () use ($claims) { + $claims['user_identifier'] = $claims[$this->claim]; + + return $this->createUser($claims); + }), $claims); + } catch (\Exception $e) { + $this->logger?->error('An error occurred while decoding and validating the token.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } + + private function loadAndVerifyJws(string $accessToken, JWKSet $jwkset): array + { + // Decode the token + $jwsVerifier = new JWSVerifier($this->signatureAlgorithm); + $serializerManager = new JWSSerializerManager([new JwsCompactSerializer()]); + $jws = $serializerManager->unserialize($accessToken); + + // Verify the signature + if (!$jwsVerifier->verifyWithKeySet($jws, $jwkset, 0)) { + throw new InvalidSignatureException(); + } + + $headerCheckerManager = new Checker\HeaderCheckerManager([ + new Checker\AlgorithmChecker($this->signatureAlgorithm->list()), + ], [ + new JWSTokenSupport(), + ]); + // if this check fails, an InvalidHeaderException is thrown + $headerCheckerManager->check($jws, 0); + + return json_decode($jws->getPayload(), true); + } + + private function verifyClaims(array $claims): array + { + // Verify the claims + $checkers = [ + new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\AudienceChecker($this->audience), + new Checker\IssuerChecker($this->issuers), + ]; + $claimCheckerManager = new ClaimCheckerManager($checkers); + + // if this check fails, an InvalidClaimException is thrown + return $claimCheckerManager->check($claims); + } + + private function decryptIfNeeded(string $accessToken): string + { + if (null === $this->decryptionKeyset || null === $this->decryptionAlgorithms) { + $this->logger?->debug('The encrypted tokens (JWE) are not supported. Skipping.'); + + return $accessToken; + } + + $jweHeaderChecker = new Checker\HeaderCheckerManager( + [ + new Checker\AlgorithmChecker($this->decryptionAlgorithms->list()), + new Checker\CallableChecker('enc', fn ($value) => \in_array($value, $this->decryptionAlgorithms->list())), + new Checker\CallableChecker('cty', fn ($value) => 'JWT' === $value), + new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + ], + [new JWETokenSupport()] + ); + $jweDecrypter = new JWEDecrypter($this->decryptionAlgorithms, null); + $serializerManager = new JWESerializerManager([new JweCompactSerializer()]); + try { + $jwe = $serializerManager->unserialize($accessToken); + $jweHeaderChecker->check($jwe, 0); + $result = $jweDecrypter->decryptUsingKeySet($jwe, $this->decryptionKeyset, 0); + if (false === $result) { + throw new \RuntimeException('The JWE could not be decrypted.'); + } + + $payload = $jwe->getPayload(); + if (null === $payload) { + throw new \RuntimeException('The JWE payload is empty.'); + } + + return $payload; + } catch (\InvalidArgumentException|\RuntimeException $e) { + if ($this->enforceEncryption) { + $this->logger?->error('An error occurred while decrypting the token.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw new BadCredentialsException('Encrypted token is required.', 0, $e); + } + $this->logger?->debug('The token decryption failed. Skipping as not mandatory.'); + + return $accessToken; + } + } +} diff --git a/AccessToken/Oidc/OidcTrait.php b/AccessToken/Oidc/OidcTrait.php new file mode 100644 index 00000000..d5ab61ff --- /dev/null +++ b/AccessToken/Oidc/OidcTrait.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Symfony\Component\Security\Core\User\OidcUser; + +use function Symfony\Component\String\u; + +/** + * Creates {@see OidcUser} from claims. + * + * @internal + */ +trait OidcTrait +{ + private function createUser(array $claims): OidcUser + { + if (!\function_exists('Symfony\Component\String\u')) { + throw new \LogicException('You cannot use the "OidcUserInfoTokenHandler" since the String component is not installed. Try running "composer require symfony/string".'); + } + + foreach ($claims as $claim => $value) { + unset($claims[$claim]); + if ('' === $value || null === $value) { + continue; + } + $claims[u($claim)->camel()->toString()] = $value; + } + + if (isset($claims['updatedAt']) && '' !== $claims['updatedAt']) { + $claims['updatedAt'] = (new \DateTimeImmutable())->setTimestamp($claims['updatedAt']); + } + + if (\array_key_exists('emailVerified', $claims) && null !== $claims['emailVerified'] && '' !== $claims['emailVerified']) { + $claims['emailVerified'] = (bool) $claims['emailVerified']; + } + + if (\array_key_exists('phoneNumberVerified', $claims) && null !== $claims['phoneNumberVerified'] && '' !== $claims['phoneNumberVerified']) { + $claims['phoneNumberVerified'] = (bool) $claims['phoneNumberVerified']; + } + + return new OidcUser(...$claims); + } +} diff --git a/AccessToken/Oidc/OidcUserInfoTokenHandler.php b/AccessToken/Oidc/OidcUserInfoTokenHandler.php new file mode 100644 index 00000000..00406332 --- /dev/null +++ b/AccessToken/Oidc/OidcUserInfoTokenHandler.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * The token handler validates the token on the OIDC server and retrieves the user identifier. + */ +final class OidcUserInfoTokenHandler implements AccessTokenHandlerInterface +{ + use OidcTrait; + + private ?CacheInterface $discoveryCache = null; + private ?string $oidcConfigurationCacheKey = null; + + public function __construct( + private HttpClientInterface $client, + private ?LoggerInterface $logger = null, + private string $claim = 'sub', + ) { + } + + public function enableDiscovery(CacheInterface $cache, string $oidcConfigurationCacheKey): void + { + $this->discoveryCache = $cache; + $this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey; + } + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + if (null !== $this->discoveryCache) { + try { + // Call OIDC discovery to retrieve userinfo endpoint + // OIDC configuration is stored in cache + $oidcConfiguration = json_decode($this->discoveryCache->get($this->oidcConfigurationCacheKey, function (): string { + $response = $this->client->request('GET', '.well-known/openid-configuration'); + + return $response->getContent(); + }), true, 512, \JSON_THROW_ON_ERROR); + } catch (\Throwable $e) { + $this->logger?->error('An error occurred while requesting OIDC configuration.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } + + try { + // Call the OIDC server to retrieve the user info + // If the token is invalid or expired, the OIDC server will return an error + $claims = $this->client->request('GET', $this->discoveryCache ? $oidcConfiguration['userinfo_endpoint'] : '', [ + 'auth_bearer' => $accessToken, + ])->toArray(); + + if (empty($claims[$this->claim])) { + throw new MissingClaimException(\sprintf('"%s" claim not found on OIDC server response.', $this->claim)); + } + + // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate + return new UserBadge($claims[$this->claim], new FallbackUserLoader(function () use ($claims) { + $claims['user_identifier'] = $claims[$this->claim]; + + return $this->createUser($claims); + }), $claims); + } catch (\Exception $e) { + $this->logger?->error('An error occurred on OIDC server.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } +} diff --git a/AccessToken/QueryAccessTokenExtractor.php b/AccessToken/QueryAccessTokenExtractor.php new file mode 100644 index 00000000..ff558904 --- /dev/null +++ b/AccessToken/QueryAccessTokenExtractor.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts a token from a query string parameter. + * + * WARNING! + * Because of the security weaknesses associated with the URI method, + * including the high likelihood that the URL containing the access token will be logged, + * it SHOULD NOT be used unless it is impossible to transport the access token in the + * request header field. + * + * @author Florent Morselli + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.3 + */ +final class QueryAccessTokenExtractor implements AccessTokenExtractorInterface +{ + public const PARAMETER = 'access_token'; + + public function __construct( + private readonly string $parameter = self::PARAMETER, + ) { + } + + public function extractAccessToken(Request $request): ?string + { + $parameter = $request->query->get($this->parameter); + + return \is_string($parameter) ? $parameter : null; + } +} diff --git a/Attribute/CurrentUser.php b/Attribute/CurrentUser.php index 413f982e..ed17c48c 100644 --- a/Attribute/CurrentUser.php +++ b/Attribute/CurrentUser.php @@ -11,10 +11,21 @@ namespace Symfony\Component\Security\Http\Attribute; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; +use Symfony\Component\Security\Http\Controller\UserValueResolver; + /** * Indicates that a controller argument should receive the current logged user. */ #[\Attribute(\Attribute::TARGET_PARAMETER)] -class CurrentUser +class CurrentUser extends ValueResolver { + /** + * @param bool $disabled Whether this value resolver is disabled, which allows to enable a value resolver globally while disabling it in specific cases + * @param string $resolver The class name of the resolver to use + */ + public function __construct(bool $disabled = false, string $resolver = UserValueResolver::class) + { + parent::__construct($resolver, $disabled); + } } diff --git a/Attribute/IsCsrfTokenValid.php b/Attribute/IsCsrfTokenValid.php new file mode 100644 index 00000000..6226fb60 --- /dev/null +++ b/Attribute/IsCsrfTokenValid.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +use Symfony\Component\ExpressionLanguage\Expression; + +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class IsCsrfTokenValid +{ + public function __construct( + /** + * Sets the id, or an Expression evaluated to the id, used when generating the token. + */ + public string|Expression $id, + + /** + * Sets the key of the request that contains the actual token value that should be validated. + */ + public ?string $tokenKey = '_token', + + /** + * Sets the available http methods that can be used to validate the token. + * If not set, the token will be validated for all methods. + */ + public array|string $methods = [], + ) { + } +} diff --git a/Attribute/IsGranted.php b/Attribute/IsGranted.php new file mode 100644 index 00000000..7f3fef69 --- /dev/null +++ b/Attribute/IsGranted.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpFoundation\Request; + +/** + * Checks if user has permission to access to some resource using security roles and voters. + * + * @see https://symfony.com/doc/current/security.html#roles + * + * @author Ryan Weaver + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class IsGranted +{ + /** + * @param string|Expression|\Closure(IsGrantedContext, mixed $subject):bool $attribute The attribute that will be checked against a given authentication token and optional subject + * @param array|string|Expression|\Closure(array, Request):mixed|null $subject An optional subject - e.g. the current object being voted on + * @param string|null $message A custom message when access is not granted + * @param int|null $statusCode If set, will throw HttpKernel's HttpException with the given $statusCode; if null, Security\Core's AccessDeniedException will be used + * @param int|null $exceptionCode If set, will add the exception code to thrown exception + */ + public function __construct( + public string|Expression|\Closure $attribute, + public array|string|Expression|\Closure|null $subject = null, + public ?string $message = null, + public ?int $statusCode = null, + public ?int $exceptionCode = null, + ) { + } +} diff --git a/Attribute/IsGrantedContext.php b/Attribute/IsGrantedContext.php new file mode 100644 index 00000000..87776452 --- /dev/null +++ b/Attribute/IsGrantedContext.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\User\UserInterface; + +class IsGrantedContext implements AuthorizationCheckerInterface +{ + public function __construct( + public readonly TokenInterface $token, + public readonly ?UserInterface $user, + private readonly AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->authorizationChecker->isGranted($attribute, $subject, $accessDecision); + } + + public function isAuthenticated(): bool + { + return $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_AUTHENTICATED); + } + + public function isAuthenticatedFully(): bool + { + return $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY); + } + + public function isImpersonator(): bool + { + return $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_IMPERSONATOR); + } +} diff --git a/Authentication/AuthenticationFailureHandlerInterface.php b/Authentication/AuthenticationFailureHandlerInterface.php index 10ebac14..faf5979c 100644 --- a/Authentication/AuthenticationFailureHandlerInterface.php +++ b/Authentication/AuthenticationFailureHandlerInterface.php @@ -27,11 +27,7 @@ interface AuthenticationFailureHandlerInterface { /** - * This is called when an interactive authentication attempt fails. This is - * called by authentication listeners inheriting from - * AbstractAuthenticationListener. - * - * @return Response + * This is called when an interactive authentication attempt fails. */ - public function onAuthenticationFailure(Request $request, AuthenticationException $exception); + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response; } diff --git a/Authentication/AuthenticationSuccessHandlerInterface.php b/Authentication/AuthenticationSuccessHandlerInterface.php index 25b65182..e440b36d 100644 --- a/Authentication/AuthenticationSuccessHandlerInterface.php +++ b/Authentication/AuthenticationSuccessHandlerInterface.php @@ -27,11 +27,7 @@ interface AuthenticationSuccessHandlerInterface { /** - * This is called when an interactive authentication attempt succeeds. This - * is called by authentication listeners inheriting from - * AbstractAuthenticationListener. - * - * @return Response|null + * Usually called by AuthenticatorInterface::onAuthenticationSuccess() implementations. */ - public function onAuthenticationSuccess(Request $request, TokenInterface $token); + public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response; } diff --git a/Authentication/AuthenticationUtils.php b/Authentication/AuthenticationUtils.php index ab3aa886..5add4049 100644 --- a/Authentication/AuthenticationUtils.php +++ b/Authentication/AuthenticationUtils.php @@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Http\SecurityRequestAttributes; /** * Extracts Security Errors from Request. @@ -23,46 +23,38 @@ */ class AuthenticationUtils { - private $requestStack; - - public function __construct(RequestStack $requestStack) - { - $this->requestStack = $requestStack; + public function __construct( + private RequestStack $requestStack, + ) { } - /** - * @return AuthenticationException|null - */ - public function getLastAuthenticationError(bool $clearSession = true) + public function getLastAuthenticationError(bool $clearSession = true): ?AuthenticationException { $request = $this->getRequest(); $authenticationException = null; - if ($request->attributes->has(Security::AUTHENTICATION_ERROR)) { - $authenticationException = $request->attributes->get(Security::AUTHENTICATION_ERROR); - } elseif ($request->hasSession() && ($session = $request->getSession())->has(Security::AUTHENTICATION_ERROR)) { - $authenticationException = $session->get(Security::AUTHENTICATION_ERROR); + if ($request->attributes->has(SecurityRequestAttributes::AUTHENTICATION_ERROR)) { + $authenticationException = $request->attributes->get(SecurityRequestAttributes::AUTHENTICATION_ERROR); + } elseif ($request->hasSession() && ($session = $request->getSession())->has(SecurityRequestAttributes::AUTHENTICATION_ERROR)) { + $authenticationException = $session->get(SecurityRequestAttributes::AUTHENTICATION_ERROR); if ($clearSession) { - $session->remove(Security::AUTHENTICATION_ERROR); + $session->remove(SecurityRequestAttributes::AUTHENTICATION_ERROR); } } return $authenticationException; } - /** - * @return string - */ - public function getLastUsername() + public function getLastUsername(): string { $request = $this->getRequest(); - if ($request->attributes->has(Security::LAST_USERNAME)) { - return $request->attributes->get(Security::LAST_USERNAME) ?? ''; + if ($request->attributes->has(SecurityRequestAttributes::LAST_USERNAME)) { + return $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME) ?? ''; } - return $request->hasSession() ? ($request->getSession()->get(Security::LAST_USERNAME) ?? '') : ''; + return $request->hasSession() ? ($request->getSession()->get(SecurityRequestAttributes::LAST_USERNAME) ?? '') : ''; } /** diff --git a/Authentication/AuthenticatorManager.php b/Authentication/AuthenticatorManager.php index 7fb99b87..856a5bfa 100644 --- a/Authentication/AuthenticatorManager.php +++ b/Authentication/AuthenticatorManager.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\AuthenticationEvents; @@ -29,7 +30,7 @@ use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\AuthenticationTokenCreatedEvent; use Symfony\Component\Security\Http\Event\CheckPassportEvent; @@ -46,39 +47,46 @@ */ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthenticatorInterface { - private $authenticators; - private $tokenStorage; - private $eventDispatcher; - private $eraseCredentials; - private $logger; - private $firewallName; - private $hideUserNotFoundExceptions; - private $requiredBadges; + private ExposeSecurityLevel $exposeSecurityErrors; /** * @param iterable $authenticators */ - public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true, bool $hideUserNotFoundExceptions = true, array $requiredBadges = []) - { - $this->authenticators = $authenticators; - $this->tokenStorage = $tokenStorage; - $this->eventDispatcher = $eventDispatcher; - $this->firewallName = $firewallName; - $this->logger = $logger; - $this->eraseCredentials = $eraseCredentials; - $this->hideUserNotFoundExceptions = $hideUserNotFoundExceptions; - $this->requiredBadges = $requiredBadges; + public function __construct( + private iterable $authenticators, + private TokenStorageInterface $tokenStorage, + private EventDispatcherInterface $eventDispatcher, + private string $firewallName, + private ?LoggerInterface $logger = null, + private bool $eraseCredentials = true, + ExposeSecurityLevel|bool $exposeSecurityErrors = ExposeSecurityLevel::None, + private array $requiredBadges = [], + ) { + if (\is_bool($exposeSecurityErrors)) { + trigger_deprecation('symfony/security-http', '7.3', 'Passing a boolean as "exposeSecurityErrors" parameter is deprecated, use %s value instead.', ExposeSecurityLevel::class); + + // The old parameter had an inverted meaning ($hideUserNotFoundExceptions), for that reason the current name does not reflect the behavior + $exposeSecurityErrors = $exposeSecurityErrors ? ExposeSecurityLevel::None : ExposeSecurityLevel::All; + } + + $this->exposeSecurityErrors = $exposeSecurityErrors; } /** - * @param BadgeInterface[] $badges Optionally, pass some Passport badges to use for the manual login + * @param BadgeInterface[] $badges Optionally, pass some Passport badges to use for the manual login + * @param array $attributes Optionally, pass some Passport attributes to use for the manual login */ - public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response + public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = [] /* , array $attributes = [] */): ?Response { + $attributes = 4 < \func_num_args() ? func_get_arg(4) : []; + // create an authentication token for the User - // @deprecated since Symfony 5.3, change to $user->getUserIdentifier() in 6.0 - $passport = new SelfValidatingPassport(new UserBadge(method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), function () use ($user) { return $user; }), $badges); - $token = method_exists($authenticator, 'createToken') ? $authenticator->createToken($passport, $this->firewallName) : $authenticator->createAuthenticatedToken($passport, $this->firewallName); + $passport = new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), fn () => $user), $badges); + foreach ($attributes as $k => $v) { + $passport->setAttribute($k, $v); + } + + $token = $authenticator->createToken($passport, $this->firewallName); // announce the authentication token $token = $this->eventDispatcher->dispatch(new AuthenticationTokenCreatedEvent($token, $passport))->getAuthenticatedToken(); @@ -92,7 +100,7 @@ public function supports(Request $request): ?bool if (null !== $this->logger) { $context = ['firewall_name' => $this->firewallName]; - if ($this->authenticators instanceof \Countable || \is_array($this->authenticators)) { + if (is_countable($this->authenticators)) { $context['authenticators'] = \count($this->authenticators); } @@ -103,28 +111,28 @@ public function supports(Request $request): ?bool $skippedAuthenticators = []; $lazy = true; foreach ($this->authenticators as $authenticator) { - if (null !== $this->logger) { - $this->logger->debug('Checking support on authenticator.', ['firewall_name' => $this->firewallName, 'authenticator' => \get_class($authenticator)]); + $this->logger?->debug('Checking support on authenticator.', ['firewall_name' => $this->firewallName, 'authenticator' => $authenticator::class]); + + if (!$authenticator instanceof AuthenticatorInterface) { + throw new \InvalidArgumentException(\sprintf('Authenticator "%s" must implement "%s".', get_debug_type($authenticator), AuthenticatorInterface::class)); } if (false !== $supports = $authenticator->supports($request)) { $authenticators[] = $authenticator; $lazy = $lazy && null === $supports; } else { - if (null !== $this->logger) { - $this->logger->debug('Authenticator does not support the request.', ['firewall_name' => $this->firewallName, 'authenticator' => \get_class($authenticator)]); - } + $this->logger?->debug('Authenticator does not support the request.', ['firewall_name' => $this->firewallName, 'authenticator' => $authenticator::class]); $skippedAuthenticators[] = $authenticator; } } + $request->attributes->set('_security_skipped_authenticators', $skippedAuthenticators); + $request->attributes->set('_security_authenticators', $authenticators); + if (!$authenticators) { return false; } - $request->attributes->set('_security_authenticators', $authenticators); - $request->attributes->set('_security_skipped_authenticators', $skippedAuthenticators); - return $lazy ? null : true; } @@ -151,18 +159,14 @@ private function executeAuthenticators(array $authenticators, Request $request): // eagerly (before token storage is initialized), whereas authenticate() is called // lazily (after initialization). if (false === $authenticator->supports($request)) { - if (null !== $this->logger) { - $this->logger->debug('Skipping the "{authenticator}" authenticator as it did not support the request.', ['authenticator' => \get_class($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)]); - } + $this->logger?->debug('Skipping the "{authenticator}" authenticator as it did not support the request.', ['authenticator' => ($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)::class]); continue; } $response = $this->executeAuthenticator($authenticator, $request); if (null !== $response) { - if (null !== $this->logger) { - $this->logger->debug('The "{authenticator}" authenticator set the response. Any later authenticator will not be called', ['authenticator' => \get_class($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)]); - } + $this->logger?->debug('The "{authenticator}" authenticator set the response. Any later authenticator will not be called', ['authenticator' => ($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)::class]); return $response; } @@ -188,32 +192,30 @@ private function executeAuthenticator(AuthenticatorInterface $authenticator, Req $resolvedBadges = []; foreach ($passport->getBadges() as $badge) { if (!$badge->isResolved()) { - throw new BadCredentialsException(sprintf('Authentication failed: Security badge "%s" is not resolved, did you forget to register the correct listeners?', get_debug_type($badge))); + throw new BadCredentialsException(\sprintf('Authentication failed: Security badge "%s" is not resolved, did you forget to register the correct listeners?', get_debug_type($badge))); } - $resolvedBadges[] = \get_class($badge); + $resolvedBadges[] = $badge::class; } $missingRequiredBadges = array_diff($this->requiredBadges, $resolvedBadges); if ($missingRequiredBadges) { - throw new BadCredentialsException(sprintf('Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "%s".', implode('", "', $missingRequiredBadges))); + throw new BadCredentialsException(\sprintf('Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "%s".', implode('", "', $missingRequiredBadges))); } // create the authentication token - $authenticatedToken = method_exists($authenticator, 'createToken') ? $authenticator->createToken($passport, $this->firewallName) : $authenticator->createAuthenticatedToken($passport, $this->firewallName); + $authenticatedToken = $authenticator->createToken($passport, $this->firewallName); // announce the authentication token $authenticatedToken = $this->eventDispatcher->dispatch(new AuthenticationTokenCreatedEvent($authenticatedToken, $passport))->getAuthenticatedToken(); - if (true === $this->eraseCredentials) { - $authenticatedToken->eraseCredentials(); + if ($this->eraseCredentials) { + self::checkEraseCredentials($authenticatedToken)?->eraseCredentials(); } $this->eventDispatcher->dispatch(new AuthenticationSuccessEvent($authenticatedToken), AuthenticationEvents::AUTHENTICATION_SUCCESS); - if (null !== $this->logger) { - $this->logger->info('Authenticator successful!', ['token' => $authenticatedToken, 'authenticator' => \get_class($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)]); - } + $this->logger?->info('Authenticator successful!', ['token' => $authenticatedToken, 'authenticator' => ($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)::class]); } catch (AuthenticationException $e) { // oh no! Authentication failed! $response = $this->handleAuthenticationFailure($e, $request, $authenticator, $passport); @@ -230,21 +232,13 @@ private function executeAuthenticator(AuthenticatorInterface $authenticator, Req return $response; } - if (null !== $this->logger) { - $this->logger->debug('Authenticator set no success response: request continues.', ['authenticator' => \get_class($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)]); - } + $this->logger?->debug('Authenticator set no success response: request continues.', ['authenticator' => ($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)::class]); return null; } - private function handleAuthenticationSuccess(TokenInterface $authenticatedToken, PassportInterface $passport, Request $request, AuthenticatorInterface $authenticator, ?TokenInterface $previousToken): ?Response + private function handleAuthenticationSuccess(TokenInterface $authenticatedToken, Passport $passport, Request $request, AuthenticatorInterface $authenticator, ?TokenInterface $previousToken): ?Response { - // @deprecated since Symfony 5.3 - $user = $authenticatedToken->getUser(); - if ($user instanceof UserInterface && !method_exists($user, 'getUserIdentifier')) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "getUserIdentifier(): string" in user class "%s" is deprecated. This method will replace "getUsername()" in Symfony 6.0.', get_debug_type($authenticatedToken->getUser())); - } - $this->tokenStorage->setToken($authenticatedToken); $response = $authenticator->onAuthenticationSuccess($request, $authenticatedToken, $this->firewallName); @@ -261,21 +255,19 @@ private function handleAuthenticationSuccess(TokenInterface $authenticatedToken, /** * Handles an authentication failure and returns the Response for the authenticator. */ - private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator, ?PassportInterface $passport): ?Response + private function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, AuthenticatorInterface $authenticator, ?Passport $passport): ?Response { - if (null !== $this->logger) { - $this->logger->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => \get_class($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)]); - } + $this->logger?->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => ($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)::class]); // Avoid leaking error details in case of invalid user (e.g. user not found or invalid account status) // to prevent user enumeration via response content comparison - if ($this->hideUserNotFoundExceptions && ($authenticationException instanceof UserNotFoundException || ($authenticationException instanceof AccountStatusException && !$authenticationException instanceof CustomUserMessageAccountStatusException))) { + if ($this->isSensitiveException($authenticationException)) { $authenticationException = new BadCredentialsException('Bad credentials.', 0, $authenticationException); } $response = $authenticator->onAuthenticationFailure($request, $authenticationException); if (null !== $response && null !== $this->logger) { - $this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => \get_class($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)]); + $this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => ($authenticator instanceof TraceableAuthenticator ? $authenticator->getAuthenticator() : $authenticator)::class]); } $this->eventDispatcher->dispatch($loginFailureEvent = new LoginFailureEvent($authenticationException, $authenticator, $request, $response, $this->firewallName, $passport)); @@ -283,4 +275,54 @@ private function handleAuthenticationFailure(AuthenticationException $authentica // returning null is ok, it means they want the request to continue return $loginFailureEvent->getResponse(); } + + private function isSensitiveException(AuthenticationException $exception): bool + { + if (ExposeSecurityLevel::All !== $this->exposeSecurityErrors && $exception instanceof UserNotFoundException) { + return true; + } + + if (ExposeSecurityLevel::None === $this->exposeSecurityErrors && $exception instanceof AccountStatusException && !$exception instanceof CustomUserMessageAccountStatusException) { + return true; + } + + return false; + } + + /** + * @deprecated since Symfony 7.3 + */ + private static function checkEraseCredentials(TokenInterface|UserInterface|null $token): TokenInterface|UserInterface|null + { + if (!$token || !method_exists($token, 'eraseCredentials')) { + return null; + } + + static $genericImplementations = []; + $m = null; + + if (!isset($genericImplementations[$token::class])) { + $m = new \ReflectionMethod($token, 'eraseCredentials'); + $genericImplementations[$token::class] = AbstractToken::class === $m->class; + } + + if ($genericImplementations[$token::class]) { + return self::checkEraseCredentials($token->getUser()); + } + + static $deprecatedImplementations = []; + + if (!isset($deprecatedImplementations[$token::class])) { + $m ??= new \ReflectionMethod($token, 'eraseCredentials'); + $deprecatedImplementations[$token::class] = !$m->getAttributes(\Deprecated::class); + } + + if ($deprecatedImplementations[$token::class]) { + trigger_deprecation('symfony/security-http', '7.3', 'Implementing "%s::eraseCredentials()" is deprecated since Symfony 7.3; add the #[\Deprecated] attribute on the method to signal its either empty or that you moved the logic elsewhere, typically to the "__serialize()" method.', get_debug_type($token)); + + return $token; + } + + return null; + } } diff --git a/Authentication/CustomAuthenticationFailureHandler.php b/Authentication/CustomAuthenticationFailureHandler.php index f49f1808..e44d4aa1 100644 --- a/Authentication/CustomAuthenticationFailureHandler.php +++ b/Authentication/CustomAuthenticationFailureHandler.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Security\Http\Authentication; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; /** @@ -19,23 +20,19 @@ */ class CustomAuthenticationFailureHandler implements AuthenticationFailureHandlerInterface { - private $handler; - /** * @param array $options Options for processing a successful authentication attempt */ - public function __construct(AuthenticationFailureHandlerInterface $handler, array $options) - { - $this->handler = $handler; + public function __construct( + private AuthenticationFailureHandlerInterface $handler, + array $options, + ) { if (method_exists($handler, 'setOptions')) { $this->handler->setOptions($options); } } - /** - * {@inheritdoc} - */ - public function onAuthenticationFailure(Request $request, AuthenticationException $exception) + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { return $this->handler->onAuthenticationFailure($request, $exception); } diff --git a/Authentication/CustomAuthenticationSuccessHandler.php b/Authentication/CustomAuthenticationSuccessHandler.php index fd318b42..005bbeb9 100644 --- a/Authentication/CustomAuthenticationSuccessHandler.php +++ b/Authentication/CustomAuthenticationSuccessHandler.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Security\Http\Authentication; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; /** @@ -19,31 +20,24 @@ */ class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface { - private $handler; - /** * @param array $options Options for processing a successful authentication attempt */ - public function __construct(AuthenticationSuccessHandlerInterface $handler, array $options, string $firewallName) - { - $this->handler = $handler; + public function __construct( + private AuthenticationSuccessHandlerInterface $handler, + array $options, + string $firewallName, + ) { if (method_exists($handler, 'setOptions')) { $this->handler->setOptions($options); } if (method_exists($handler, 'setFirewallName')) { $this->handler->setFirewallName($firewallName); - } elseif (method_exists($handler, 'setProviderKey')) { - trigger_deprecation('symfony/security-http', '5.2', 'Method "%s::setProviderKey()" is deprecated, rename the method to "setFirewallName()" instead.', \get_class($handler)); - - $this->handler->setProviderKey($firewallName); } } - /** - * {@inheritdoc} - */ - public function onAuthenticationSuccess(Request $request, TokenInterface $token) + public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response { return $this->handler->onAuthenticationSuccess($request, $token); } diff --git a/Authentication/DefaultAuthenticationFailureHandler.php b/Authentication/DefaultAuthenticationFailureHandler.php index 2d1fa8c8..4003b7d1 100644 --- a/Authentication/DefaultAuthenticationFailureHandler.php +++ b/Authentication/DefaultAuthenticationFailureHandler.php @@ -13,11 +13,12 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\ParameterBagUtils; +use Symfony\Component\Security\Http\SecurityRequestAttributes; /** * Class with the default authentication failure handling logic. @@ -31,44 +32,37 @@ */ class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandlerInterface { - protected $httpKernel; - protected $httpUtils; - protected $logger; - protected $options; - protected $defaultOptions = [ + protected array $options; + protected array $defaultOptions = [ 'failure_path' => null, 'failure_forward' => false, 'login_path' => '/login', 'failure_path_parameter' => '_failure_path', ]; - public function __construct(HttpKernelInterface $httpKernel, HttpUtils $httpUtils, array $options = [], ?LoggerInterface $logger = null) - { - $this->httpKernel = $httpKernel; - $this->httpUtils = $httpUtils; - $this->logger = $logger; + public function __construct( + protected HttpKernelInterface $httpKernel, + protected HttpUtils $httpUtils, + array $options = [], + protected ?LoggerInterface $logger = null, + ) { $this->setOptions($options); } /** * Gets the options. - * - * @return array */ - public function getOptions() + public function getOptions(): array { return $this->options; } - public function setOptions(array $options) + public function setOptions(array $options): void { $this->options = array_merge($this->defaultOptions, $options); } - /** - * {@inheritdoc} - */ - public function onAuthenticationFailure(Request $request, AuthenticationException $exception) + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { $options = $this->options; $failureUrl = ParameterBagUtils::getRequestParameterValue($request, $options['failure_path_parameter']); @@ -76,27 +70,25 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio if (\is_string($failureUrl) && (str_starts_with($failureUrl, '/') || str_starts_with($failureUrl, 'http'))) { $options['failure_path'] = $failureUrl; } elseif ($this->logger && $failureUrl) { - $this->logger->debug(sprintf('Ignoring query parameter "%s": not a valid URL.', $options['failure_path_parameter'])); + $this->logger->debug(\sprintf('Ignoring query parameter "%s": not a valid URL.', $options['failure_path_parameter'])); } - $options['failure_path'] ?? $options['failure_path'] = $options['login_path']; + $options['failure_path'] ??= $options['login_path']; if ($options['failure_forward']) { - if (null !== $this->logger) { - $this->logger->debug('Authentication failure, forward triggered.', ['failure_path' => $options['failure_path']]); - } + $this->logger?->debug('Authentication failure, forward triggered.', ['failure_path' => $options['failure_path']]); $subRequest = $this->httpUtils->createRequest($request, $options['failure_path']); - $subRequest->attributes->set(Security::AUTHENTICATION_ERROR, $exception); + $subRequest->attributes->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); return $this->httpKernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); } - if (null !== $this->logger) { - $this->logger->debug('Authentication failure, redirect triggered.', ['failure_path' => $options['failure_path']]); - } + $this->logger?->debug('Authentication failure, redirect triggered.', ['failure_path' => $options['failure_path']]); - $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); + if (!$request->attributes->getBoolean('_stateless')) { + $request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); + } return $this->httpUtils->createRedirectResponse($request, $options['failure_path']); } diff --git a/Authentication/DefaultAuthenticationSuccessHandler.php b/Authentication/DefaultAuthenticationSuccessHandler.php index d2d84740..5f69def4 100644 --- a/Authentication/DefaultAuthenticationSuccessHandler.php +++ b/Authentication/DefaultAuthenticationSuccessHandler.php @@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\ParameterBagUtils; @@ -29,13 +30,9 @@ class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandle { use TargetPathTrait; - protected $httpUtils; - protected $logger; - protected $options; - /** @deprecated since Symfony 5.2, use $firewallName instead */ - protected $providerKey; - protected $firewallName; - protected $defaultOptions = [ + protected array $options; + protected ?string $firewallName = null; + protected array $defaultOptions = [ 'always_use_default_target_path' => false, 'default_target_path' => '/', 'login_path' => '/login', @@ -46,85 +43,46 @@ class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandle /** * @param array $options Options for processing a successful authentication attempt */ - public function __construct(HttpUtils $httpUtils, array $options = [], ?LoggerInterface $logger = null) - { - $this->httpUtils = $httpUtils; - $this->logger = $logger; + public function __construct( + protected HttpUtils $httpUtils, + array $options = [], + protected ?LoggerInterface $logger = null, + ) { $this->setOptions($options); } - /** - * {@inheritdoc} - */ - public function onAuthenticationSuccess(Request $request, TokenInterface $token) + public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response { return $this->httpUtils->createRedirectResponse($request, $this->determineTargetUrl($request)); } /** * Gets the options. - * - * @return array */ - public function getOptions() + public function getOptions(): array { return $this->options; } - public function setOptions(array $options) + public function setOptions(array $options): void { $this->options = array_merge($this->defaultOptions, $options); } - /** - * Get the provider key. - * - * @return string - * - * @deprecated since Symfony 5.2, use getFirewallName() instead - */ - public function getProviderKey() - { - if (1 !== \func_num_args() || true !== func_get_arg(0)) { - trigger_deprecation('symfony/security-core', '5.2', 'Method "%s()" is deprecated, use "getFirewallName()" instead.', __METHOD__); - } - - if ($this->providerKey !== $this->firewallName) { - trigger_deprecation('symfony/security-core', '5.2', 'The "%1$s::$providerKey" property is deprecated, use "%1$s::$firewallName" instead.', __CLASS__); - - return $this->providerKey; - } - - return $this->firewallName; - } - - public function setProviderKey(string $providerKey) - { - if (2 !== \func_num_args() || true !== func_get_arg(1)) { - trigger_deprecation('symfony/security-http', '5.2', 'Method "%s" is deprecated, use "setFirewallName()" instead.', __METHOD__); - } - - $this->providerKey = $providerKey; - } - public function getFirewallName(): ?string { - return $this->getProviderKey(true); + return $this->firewallName; } public function setFirewallName(string $firewallName): void { - $this->setProviderKey($firewallName, true); - $this->firewallName = $firewallName; } /** * Builds the target URL according to the defined options. - * - * @return string */ - protected function determineTargetUrl(Request $request) + protected function determineTargetUrl(Request $request): string { if ($this->options['always_use_default_target_path']) { return $this->options['default_target_path']; @@ -137,11 +95,11 @@ protected function determineTargetUrl(Request $request) } if ($this->logger && $targetUrl) { - $this->logger->debug(sprintf('Ignoring query parameter "%s": not a valid URL.', $this->options['target_path_parameter'])); + $this->logger->debug(\sprintf('Ignoring query parameter "%s": not a valid URL.', $this->options['target_path_parameter'])); } $firewallName = $this->getFirewallName(); - if (null !== $firewallName && $targetUrl = $this->getTargetPath($request->getSession(), $firewallName)) { + if (null !== $firewallName && !$request->attributes->getBoolean('_stateless') && $targetUrl = $this->getTargetPath($request->getSession(), $firewallName)) { $this->removeTargetPath($request->getSession(), $firewallName); return $targetUrl; diff --git a/Authentication/ExposeSecurityLevel.php b/Authentication/ExposeSecurityLevel.php new file mode 100644 index 00000000..c80fd496 --- /dev/null +++ b/Authentication/ExposeSecurityLevel.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authentication; + +/** + * @author Christian Gripp + */ +enum ExposeSecurityLevel: string +{ + case None = 'none'; + case AccountStatus = 'account_status'; + case All = 'all'; +} diff --git a/Authentication/NoopAuthenticationManager.php b/Authentication/NoopAuthenticationManager.php deleted file mode 100644 index c5a0d7b7..00000000 --- a/Authentication/NoopAuthenticationManager.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Authentication; - -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; - -/** - * This class is used when the authenticator system is activated. - * - * This is used to not break AuthenticationChecker and ContextListener when - * using the authenticator system. - * - * @author Wouter de Jong - * - * @internal - */ -class NoopAuthenticationManager implements AuthenticationManagerInterface -{ - public function authenticate(TokenInterface $token): TokenInterface - { - return $token; - } -} diff --git a/Authentication/UserAuthenticatorInterface.php b/Authentication/UserAuthenticatorInterface.php index a59a792e..f5c5f252 100644 --- a/Authentication/UserAuthenticatorInterface.php +++ b/Authentication/UserAuthenticatorInterface.php @@ -26,7 +26,8 @@ interface UserAuthenticatorInterface * Convenience method to programmatically login a user and return a * Response *if any* for success. * - * @param BadgeInterface[] $badges Optionally, pass some Passport badges to use for the manual login + * @param BadgeInterface[] $badges Optionally, pass some Passport badges to use for the manual login + * @param array $attributes Optionally, pass some Passport attributes to use for the manual login */ - public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = []): ?Response; + public function authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = [] /* , array $attributes = [] */): ?Response; } diff --git a/Authenticator/AbstractAuthenticator.php b/Authenticator/AbstractAuthenticator.php index 673274d9..a7cee7a8 100644 --- a/Authenticator/AbstractAuthenticator.php +++ b/Authenticator/AbstractAuthenticator.php @@ -12,10 +12,7 @@ namespace Symfony\Component\Security\Http\Authenticator; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; -use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; /** @@ -31,25 +28,6 @@ abstract class AbstractAuthenticator implements AuthenticatorInterface */ public function createToken(Passport $passport, string $firewallName): TokenInterface { - if (self::class !== (new \ReflectionMethod($this, 'createAuthenticatedToken'))->getDeclaringClass()->getName() && self::class === (new \ReflectionMethod($this, 'createToken'))->getDeclaringClass()->getName()) { - return $this->createAuthenticatedToken($passport, $firewallName); - } - - return new PostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); - } - - /** - * @deprecated since Symfony 5.4, use {@link createToken()} instead - */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - // @deprecated since Symfony 5.4 - if (!$passport instanceof UserPassportInterface) { - throw new LogicException(sprintf('Passport does not contain a user, overwrite "createToken()" in "%s" to create a custom authentication token.', static::class)); - } - - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use "%s::createToken()" instead.', __METHOD__, __CLASS__); - return new PostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); } } diff --git a/Authenticator/AbstractLoginFormAuthenticator.php b/Authenticator/AbstractLoginFormAuthenticator.php index f737b065..21835bd3 100644 --- a/Authenticator/AbstractLoginFormAuthenticator.php +++ b/Authenticator/AbstractLoginFormAuthenticator.php @@ -15,8 +15,8 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; +use Symfony\Component\Security\Http\SecurityRequestAttributes; /** * A base class to make form login authentication easier! @@ -31,8 +31,6 @@ abstract class AbstractLoginFormAuthenticator extends AbstractAuthenticator impl abstract protected function getLoginUrl(Request $request): string; /** - * {@inheritdoc} - * * Override to change the request conditions that have to be * matched in order to handle the login form submit. * @@ -50,7 +48,7 @@ public function supports(Request $request): bool public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { if ($request->hasSession()) { - $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); + $request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); } $url = $this->getLoginUrl($request); diff --git a/Authenticator/AbstractPreAuthenticatedAuthenticator.php b/Authenticator/AbstractPreAuthenticatedAuthenticator.php index 993b2191..5017e991 100644 --- a/Authenticator/AbstractPreAuthenticatedAuthenticator.php +++ b/Authenticator/AbstractPreAuthenticatedAuthenticator.php @@ -23,7 +23,6 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PreAuthenticatedUserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; /** @@ -37,17 +36,12 @@ */ abstract class AbstractPreAuthenticatedAuthenticator implements InteractiveAuthenticatorInterface { - private $userProvider; - private $tokenStorage; - private $firewallName; - private $logger; - - public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, ?LoggerInterface $logger = null) - { - $this->userProvider = $userProvider; - $this->tokenStorage = $tokenStorage; - $this->firewallName = $firewallName; - $this->logger = $logger; + public function __construct( + private UserProviderInterface $userProvider, + private TokenStorageInterface $tokenStorage, + private string $firewallName, + private ?LoggerInterface $logger = null, + ) { } /** @@ -65,17 +59,13 @@ public function supports(Request $request): ?bool } catch (BadCredentialsException $e) { $this->clearToken($e); - if (null !== $this->logger) { - $this->logger->debug('Skipping pre-authenticated authenticator as a BadCredentialsException is thrown.', ['exception' => $e, 'authenticator' => static::class]); - } + $this->logger?->debug('Skipping pre-authenticated authenticator as a BadCredentialsException is thrown.', ['exception' => $e, 'authenticator' => static::class]); return false; } if (null === $username) { - if (null !== $this->logger) { - $this->logger->debug('Skipping pre-authenticated authenticator no username could be extracted.', ['authenticator' => static::class]); - } + $this->logger?->debug('Skipping pre-authenticated authenticator no username could be extracted.', ['authenticator' => static::class]); return false; } @@ -84,9 +74,7 @@ public function supports(Request $request): ?bool $token = $this->tokenStorage->getToken(); if ($token instanceof PreAuthenticatedToken && $this->firewallName === $token->getFirewallName() && $token->getUserIdentifier() === $username) { - if (null !== $this->logger) { - $this->logger->debug('Skipping pre-authenticated authenticator as the user already has an existing session.', ['authenticator' => static::class]); - } + $this->logger?->debug('Skipping pre-authenticated authenticator as the user already has an existing session.', ['authenticator' => static::class]); return false; } @@ -98,28 +86,9 @@ public function supports(Request $request): ?bool public function authenticate(Request $request): Passport { - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - $method = 'loadUserByIdentifier'; - if (!method_exists($this->userProvider, 'loadUserByIdentifier')) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); - - $method = 'loadUserByUsername'; - } - - return new SelfValidatingPassport( - new UserBadge($request->attributes->get('_pre_authenticated_username'), [$this->userProvider, $method]), - [new PreAuthenticatedUserBadge()] - ); - } - - /** - * @deprecated since Symfony 5.4, use {@link createToken()} instead - */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use "%s::createToken()" instead.', __METHOD__, __CLASS__); + $userBadge = new UserBadge($request->attributes->get('_pre_authenticated_username'), $this->userProvider->loadUserByIdentifier(...)); - return $this->createToken($passport, $firewallName); + return new SelfValidatingPassport($userBadge, [new PreAuthenticatedUserBadge()]); } public function createToken(Passport $passport, string $firewallName): TokenInterface @@ -150,9 +119,7 @@ private function clearToken(AuthenticationException $exception): void if ($token instanceof PreAuthenticatedToken && $this->firewallName === $token->getFirewallName()) { $this->tokenStorage->setToken(null); - if (null !== $this->logger) { - $this->logger->info('Cleared pre-authenticated token due to an exception.', ['exception' => $exception]); - } + $this->logger?->info('Cleared pre-authenticated token due to an exception.', ['exception' => $exception]); } } } diff --git a/Authenticator/AccessTokenAuthenticator.php b/Authenticator/AccessTokenAuthenticator.php new file mode 100644 index 00000000..75d69ed6 --- /dev/null +++ b/Authenticator/AccessTokenAuthenticator.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Provides an implementation of the RFC6750 of an authentication via + * an access token. + * + * @author Florent Morselli + */ +class AccessTokenAuthenticator implements AuthenticatorInterface +{ + private ?TranslatorInterface $translator = null; + + public function __construct( + private readonly AccessTokenHandlerInterface $accessTokenHandler, + private readonly AccessTokenExtractorInterface $accessTokenExtractor, + private readonly ?UserProviderInterface $userProvider = null, + private readonly ?AuthenticationSuccessHandlerInterface $successHandler = null, + private readonly ?AuthenticationFailureHandlerInterface $failureHandler = null, + private readonly ?string $realm = null, + ) { + } + + public function supports(Request $request): ?bool + { + return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null; + } + + public function authenticate(Request $request): Passport + { + $accessToken = $this->accessTokenExtractor->extractAccessToken($request); + if (!$accessToken) { + throw new BadCredentialsException('Invalid credentials.'); + } + + $userBadge = $this->accessTokenHandler->getUserBadgeFrom($accessToken); + if ($this->userProvider && (null === $userBadge->getUserLoader() || $userBadge->getUserLoader() instanceof FallbackUserLoader)) { + $userBadge->setUserLoader($this->userProvider->loadUserByIdentifier(...)); + } + + return new SelfValidatingPassport($userBadge); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + return new PostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return $this->successHandler?->onAuthenticationSuccess($request, $token); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + if (null !== $this->failureHandler) { + return $this->failureHandler->onAuthenticationFailure($request, $exception); + } + + if (null !== $this->translator) { + $errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(), 'security'); + } else { + $errorMessage = strtr($exception->getMessageKey(), $exception->getMessageData()); + } + + return new Response( + null, + Response::HTTP_UNAUTHORIZED, + ['WWW-Authenticate' => $this->getAuthenticateHeader($errorMessage)] + ); + } + + public function setTranslator(?TranslatorInterface $translator): void + { + $this->translator = $translator; + } + + /** + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3 + */ + private function getAuthenticateHeader(?string $errorDescription = null): string + { + $data = [ + 'realm' => $this->realm, + 'error' => 'invalid_token', + 'error_description' => $errorDescription, + ]; + $values = []; + foreach ($data as $k => $v) { + if (null === $v || '' === $v) { + continue; + } + $values[] = \sprintf('%s="%s"', $k, $v); + } + + return \sprintf('Bearer %s', implode(',', $values)); + } +} diff --git a/Authenticator/AuthenticatorInterface.php b/Authenticator/AuthenticatorInterface.php index b66874fa..124e0bf9 100644 --- a/Authenticator/AuthenticatorInterface.php +++ b/Authenticator/AuthenticatorInterface.php @@ -16,7 +16,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; /** * The interface for all authenticators. @@ -24,10 +23,6 @@ * @author Ryan Weaver * @author Amaury Leroux de Lens * @author Wouter de Jong - * - * @method TokenInterface createToken(Passport $passport, string $firewallName) Creates a token for the given user. - * If you don't care about which token class is used, you can skip this method by extending - * the AbstractAuthenticator class from your authenticator. */ interface AuthenticatorInterface { @@ -51,11 +46,9 @@ public function supports(Request $request): ?bool; * You may throw any AuthenticationException in this method in case of error (e.g. * a UserNotFoundException when the user cannot be found). * - * @return Passport - * * @throws AuthenticationException */ - public function authenticate(Request $request); /* : Passport; */ + public function authenticate(Request $request): Passport; /** * Create an authenticated token for the given user. @@ -66,11 +59,9 @@ public function authenticate(Request $request); /* : Passport; */ * * @see AbstractAuthenticator * - * @param PassportInterface $passport The passport returned from authenticate() - * - * @deprecated since Symfony 5.4, use {@link createToken()} instead + * @param Passport $passport The passport returned from authenticate() */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface; + public function createToken(Passport $passport, string $firewallName): TokenInterface; /** * Called when authentication executed and was successful! diff --git a/Authenticator/Debug/TraceableAuthenticator.php b/Authenticator/Debug/TraceableAuthenticator.php index 8149ed4b..a98c2bc6 100644 --- a/Authenticator/Debug/TraceableAuthenticator.php +++ b/Authenticator/Debug/TraceableAuthenticator.php @@ -15,10 +15,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Guard\Authenticator\GuardBridgeAuthenticator; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException; use Symfony\Component\VarDumper\Caster\ClassStub; @@ -30,59 +30,75 @@ */ final class TraceableAuthenticator implements AuthenticatorInterface, InteractiveAuthenticatorInterface, AuthenticationEntryPointInterface { - private $authenticator; - private $passport; - private $duration; - private $stub; - - public function __construct(AuthenticatorInterface $authenticator) + private ?bool $supports = false; + private ?Passport $passport = null; + private ?float $duration = null; + private ClassStub|string $stub; + private ?bool $authenticated = null; + private ?AuthenticationException $exception = null; + + public function __construct(private AuthenticatorInterface $authenticator) { - $this->authenticator = $authenticator; } public function getInfo(): array { - $class = \get_class($this->authenticator instanceof GuardBridgeAuthenticator ? $this->authenticator->getGuardAuthenticator() : $this->authenticator); - return [ - 'supports' => true, + 'supports' => $this->supports, 'passport' => $this->passport, 'duration' => $this->duration, - 'stub' => $this->stub ?? $this->stub = class_exists(ClassStub::class) ? new ClassStub($class) : $class, + 'stub' => $this->stub ??= class_exists(ClassStub::class) ? new ClassStub($this->authenticator::class) : $this->authenticator::class, + 'authenticated' => $this->authenticated, + 'badges' => array_map( + static function (BadgeInterface $badge): array { + return [ + 'stub' => class_exists(ClassStub::class) ? new ClassStub($badge::class) : $badge::class, + 'resolved' => $badge->isResolved(), + ]; + }, + $this->passport?->getBadges() ?? [], + ), + 'exception' => $this->exception, ]; } public function supports(Request $request): ?bool { - return $this->authenticator->supports($request); + return $this->supports = $this->authenticator->supports($request); } - public function authenticate(Request $request): PassportInterface + public function authenticate(Request $request): Passport { $startTime = microtime(true); - $this->passport = $this->authenticator->authenticate($request); - $this->duration = microtime(true) - $startTime; + try { + $this->passport = $this->authenticator->authenticate($request); + } finally { + $this->duration = microtime(true) - $startTime; + } return $this->passport; } - public function createToken(PassportInterface $passport, string $firewallName): TokenInterface - { - return method_exists($this->authenticator, 'createToken') ? $this->authenticator->createToken($passport, $firewallName) : $this->authenticator->createAuthenticatedToken($passport, $firewallName); - } - - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + public function createToken(Passport $passport, string $firewallName): TokenInterface { - return $this->authenticator->createAuthenticatedToken($passport, $firewallName); + return $this->authenticator->createToken($passport, $firewallName); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { + $this->authenticated = true; + return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { + $this->authenticated = false; + $this->exception = $exception->getPrevious() instanceof AuthenticationException + ? $exception->getPrevious() + : $exception + ; + return $this->authenticator->onAuthenticationFailure($request, $exception); } @@ -105,7 +121,7 @@ public function getAuthenticator(): AuthenticatorInterface return $this->authenticator; } - public function __call($method, $args) + public function __call($method, $args): mixed { return $this->authenticator->{$method}(...$args); } diff --git a/Authenticator/Debug/TraceableAuthenticatorManagerListener.php b/Authenticator/Debug/TraceableAuthenticatorManagerListener.php index e67e3322..2e553fd4 100644 --- a/Authenticator/Debug/TraceableAuthenticatorManagerListener.php +++ b/Authenticator/Debug/TraceableAuthenticatorManagerListener.php @@ -15,7 +15,6 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Http\Firewall\AbstractListener; use Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener; -use Symfony\Component\VarDumper\Caster\ClassStub; use Symfony\Contracts\Service\ResetInterface; /** @@ -25,49 +24,38 @@ */ final class TraceableAuthenticatorManagerListener extends AbstractListener implements ResetInterface { - private $authenticationManagerListener; - private $authenticatorsInfo = []; - private $hasVardumper; + private array $authenticators = []; - public function __construct(AuthenticatorManagerListener $authenticationManagerListener) + public function __construct(private AuthenticatorManagerListener $authenticationManagerListener) { - $this->authenticationManagerListener = $authenticationManagerListener; - $this->hasVardumper = class_exists(ClassStub::class); } public function supports(Request $request): ?bool { - return $this->authenticationManagerListener->supports($request); - } - - public function authenticate(RequestEvent $event): void - { - $request = $event->getRequest(); - - if (!$authenticators = $request->attributes->get('_security_authenticators')) { - return; - } + $supports = $this->authenticationManagerListener->supports($request); - foreach ($request->attributes->get('_security_skipped_authenticators') as $skippedAuthenticator) { - $this->authenticatorsInfo[] = [ - 'supports' => false, - 'stub' => $this->hasVardumper ? new ClassStub(\get_class($skippedAuthenticator)) : \get_class($skippedAuthenticator), - 'passport' => null, - 'duration' => 0, - ]; + foreach ($request->attributes->get('_security_skipped_authenticators') as $authenticator) { + $this->authenticators[] = $authenticator instanceof TraceableAuthenticator + ? $authenticator + : new TraceableAuthenticator($authenticator) + ; } - foreach ($authenticators as $key => $authenticator) { - $authenticators[$key] = new TraceableAuthenticator($authenticator); + $supportedAuthenticators = []; + foreach ($request->attributes->get('_security_authenticators') as $authenticator) { + $this->authenticators[] = $supportedAuthenticators[] = $authenticator instanceof TraceableAuthenticator + ? $authenticator : + new TraceableAuthenticator($authenticator) + ; } + $request->attributes->set('_security_authenticators', $supportedAuthenticators); - $request->attributes->set('_security_authenticators', $authenticators); + return $supports; + } + public function authenticate(RequestEvent $event): void + { $this->authenticationManagerListener->authenticate($event); - - foreach ($authenticators as $authenticator) { - $this->authenticatorsInfo[] = $authenticator->getInfo(); - } } public function getAuthenticatorManagerListener(): AuthenticatorManagerListener @@ -77,11 +65,14 @@ public function getAuthenticatorManagerListener(): AuthenticatorManagerListener public function getAuthenticatorsInfo(): array { - return $this->authenticatorsInfo; + return array_map( + static fn (TraceableAuthenticator $authenticator) => $authenticator->getInfo(), + $this->authenticators + ); } public function reset(): void { - $this->authenticatorsInfo = []; + $this->authenticators = []; } } diff --git a/Authenticator/FallbackUserLoader.php b/Authenticator/FallbackUserLoader.php new file mode 100644 index 00000000..65392781 --- /dev/null +++ b/Authenticator/FallbackUserLoader.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * This wrapper serves as a marker interface to indicate badge user loaders that should not be overridden by the + * default user provider. + * + * @internal + */ +final class FallbackUserLoader +{ + public function __construct(private $inner) + { + } + + public function __invoke(mixed ...$args): ?UserInterface + { + return ($this->inner)(...$args); + } +} diff --git a/Authenticator/FormLoginAuthenticator.php b/Authenticator/FormLoginAuthenticator.php index 5b4de2b4..d8da062e 100644 --- a/Authenticator/FormLoginAuthenticator.php +++ b/Authenticator/FormLoginAuthenticator.php @@ -19,7 +19,6 @@ use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; @@ -30,9 +29,9 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\ParameterBagUtils; +use Symfony\Component\Security\Http\SecurityRequestAttributes; /** * @author Wouter de Jong @@ -42,19 +41,16 @@ */ class FormLoginAuthenticator extends AbstractLoginFormAuthenticator { - private $httpUtils; - private $userProvider; - private $successHandler; - private $failureHandler; - private $options; - private $httpKernel; - - public function __construct(HttpUtils $httpUtils, UserProviderInterface $userProvider, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options) - { - $this->httpUtils = $httpUtils; - $this->userProvider = $userProvider; - $this->successHandler = $successHandler; - $this->failureHandler = $failureHandler; + private array $options; + private HttpKernelInterface $httpKernel; + + public function __construct( + private HttpUtils $httpUtils, + private UserProviderInterface $userProvider, + private AuthenticationSuccessHandlerInterface $successHandler, + private AuthenticationFailureHandlerInterface $failureHandler, + array $options, + ) { $this->options = array_merge([ 'username_parameter' => '_username', 'password_parameter' => '_password', @@ -76,26 +72,16 @@ public function supports(Request $request): bool { return ($this->options['post_only'] ? $request->isMethod('POST') : true) && $this->httpUtils->checkRequestPath($request, $this->options['check_path']) - && ($this->options['form_only'] ? 'form' === $request->getContentType() : true); + && ($this->options['form_only'] ? 'form' === $request->getContentTypeFormat() : true); } public function authenticate(Request $request): Passport { $credentials = $this->getCredentials($request); - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - $method = 'loadUserByIdentifier'; - if (!method_exists($this->userProvider, 'loadUserByIdentifier')) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); - - $method = 'loadUserByUsername'; - } + $userBadge = new UserBadge($credentials['username'], $this->userProvider->loadUserByIdentifier(...)); + $passport = new Passport($userBadge, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]); - $passport = new Passport( - new UserBadge($credentials['username'], [$this->userProvider, $method]), - new PasswordCredentials($credentials['password']), - [new RememberMeBadge()] - ); if ($this->options['enable_csrf']) { $passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token'])); } @@ -107,16 +93,6 @@ public function authenticate(Request $request): Passport return $passport; } - /** - * @deprecated since Symfony 5.4, use {@link createToken()} instead - */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use "%s::createToken()" instead.', __METHOD__, __CLASS__); - - return $this->createToken($passport, $firewallName); - } - public function createToken(Passport $passport, string $firewallName): TokenInterface { return new UsernamePasswordToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); @@ -145,24 +121,28 @@ private function getCredentials(Request $request): array $credentials['password'] = ParameterBagUtils::getRequestParameterValue($request, $this->options['password_parameter']) ?? ''; } - if (!\is_string($credentials['username']) && (!\is_object($credentials['username']) || !method_exists($credentials['username'], '__toString'))) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username']))); + if (!\is_string($credentials['username']) && !$credentials['username'] instanceof \Stringable) { + throw new BadRequestHttpException(\sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username']))); } $credentials['username'] = trim($credentials['username']); - if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { - throw new BadCredentialsException('Invalid username.'); + if ('' === $credentials['username']) { + throw new BadCredentialsException(\sprintf('The key "%s" must be a non-empty string.', $this->options['username_parameter'])); } - $request->getSession()->set(Security::LAST_USERNAME, $credentials['username']); + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']); + + if (!\is_string($credentials['password']) && !$credentials['password'] instanceof \Stringable) { + throw new BadRequestHttpException(\sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password']))); + } - if (!\is_string($credentials['password']) && (!\is_object($credentials['password']) || !method_exists($credentials['password'], '__toString'))) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password']))); + if ('' === (string) $credentials['password']) { + throw new BadCredentialsException(\sprintf('The key "%s" must be a non-empty string.', $this->options['password_parameter'])); } - if (!\is_string($credentials['csrf_token'] ?? '') && (!\is_object($credentials['csrf_token']) || !method_exists($credentials['csrf_token'], '__toString'))) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['csrf_parameter'], \gettype($credentials['csrf_token']))); + if (!\is_string($credentials['csrf_token'] ?? '') && !$credentials['csrf_token'] instanceof \Stringable) { + throw new BadRequestHttpException(\sprintf('The key "%s" must be a string, "%s" given.', $this->options['csrf_parameter'], \gettype($credentials['csrf_token']))); } return $credentials; diff --git a/Authenticator/HttpBasicAuthenticator.php b/Authenticator/HttpBasicAuthenticator.php index 45f6e31f..d76c2be9 100644 --- a/Authenticator/HttpBasicAuthenticator.php +++ b/Authenticator/HttpBasicAuthenticator.php @@ -23,7 +23,6 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; /** @@ -34,21 +33,17 @@ */ class HttpBasicAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface { - private $realmName; - private $userProvider; - private $logger; - - public function __construct(string $realmName, UserProviderInterface $userProvider, ?LoggerInterface $logger = null) - { - $this->realmName = $realmName; - $this->userProvider = $userProvider; - $this->logger = $logger; + public function __construct( + private string $realmName, + private UserProviderInterface $userProvider, + private ?LoggerInterface $logger = null, + ) { } public function start(Request $request, ?AuthenticationException $authException = null): Response { $response = new Response(); - $response->headers->set('WWW-Authenticate', sprintf('Basic realm="%s"', $this->realmName)); + $response->headers->set('WWW-Authenticate', \sprintf('Basic realm="%s"', $this->realmName)); $response->setStatusCode(401); return $response; @@ -59,23 +54,14 @@ public function supports(Request $request): ?bool return $request->headers->has('PHP_AUTH_USER'); } - public function authenticate(Request $request): PassportInterface + public function authenticate(Request $request): Passport { $username = $request->headers->get('PHP_AUTH_USER'); $password = $request->headers->get('PHP_AUTH_PW', ''); - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - $method = 'loadUserByIdentifier'; - if (!method_exists($this->userProvider, 'loadUserByIdentifier')) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); + $userBadge = new UserBadge($username, $this->userProvider->loadUserByIdentifier(...)); + $passport = new Passport($userBadge, new PasswordCredentials($password)); - $method = 'loadUserByUsername'; - } - - $passport = new Passport( - new UserBadge($username, [$this->userProvider, $method]), - new PasswordCredentials($password) - ); if ($this->userProvider instanceof PasswordUpgraderInterface) { $passport->addBadge(new PasswordUpgradeBadge($password, $this->userProvider)); } @@ -83,16 +69,6 @@ public function authenticate(Request $request): PassportInterface return $passport; } - /** - * @deprecated since Symfony 5.4, use {@link createToken()} instead - */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use "%s::createToken()" instead.', __METHOD__, __CLASS__); - - return $this->createToken($passport, $firewallName); - } - public function createToken(Passport $passport, string $firewallName): TokenInterface { return new UsernamePasswordToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); @@ -105,9 +81,7 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { - if (null !== $this->logger) { - $this->logger->info('Basic authentication failed for user.', ['username' => $request->headers->get('PHP_AUTH_USER'), 'exception' => $exception]); - } + $this->logger?->info('Basic authentication failed for user.', ['username' => $request->headers->get('PHP_AUTH_USER'), 'exception' => $exception]); return $this->start($request, $exception); } diff --git a/Authenticator/InteractiveAuthenticatorInterface.php b/Authenticator/InteractiveAuthenticatorInterface.php index d7a6b516..ce125f0f 100644 --- a/Authenticator/InteractiveAuthenticatorInterface.php +++ b/Authenticator/InteractiveAuthenticatorInterface.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Security\Http\Authenticator; /** - * This is an extension of the authenticator interface that must + * This is an extension of the authenticator interface that may * be used by interactive authenticators. * * Interactive login requires explicit user action (e.g. a login diff --git a/Authenticator/JsonLoginAuthenticator.php b/Authenticator/JsonLoginAuthenticator.php index 105d04b9..b2cd7b42 100644 --- a/Authenticator/JsonLoginAuthenticator.php +++ b/Authenticator/JsonLoginAuthenticator.php @@ -21,17 +21,15 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Contracts\Translation\TranslatorInterface; @@ -46,31 +44,28 @@ */ class JsonLoginAuthenticator implements InteractiveAuthenticatorInterface { - private $options; - private $httpUtils; - private $userProvider; - private $propertyAccessor; - private $successHandler; - private $failureHandler; - - /** - * @var TranslatorInterface|null - */ - private $translator; - - public function __construct(HttpUtils $httpUtils, UserProviderInterface $userProvider, ?AuthenticationSuccessHandlerInterface $successHandler = null, ?AuthenticationFailureHandlerInterface $failureHandler = null, array $options = [], ?PropertyAccessorInterface $propertyAccessor = null) - { + private array $options; + private PropertyAccessorInterface $propertyAccessor; + private ?TranslatorInterface $translator = null; + + public function __construct( + private HttpUtils $httpUtils, + private UserProviderInterface $userProvider, + private ?AuthenticationSuccessHandlerInterface $successHandler = null, + private ?AuthenticationFailureHandlerInterface $failureHandler = null, + array $options = [], + ?PropertyAccessorInterface $propertyAccessor = null, + ) { $this->options = array_merge(['username_path' => 'username', 'password_path' => 'password'], $options); - $this->httpUtils = $httpUtils; - $this->successHandler = $successHandler; - $this->failureHandler = $failureHandler; - $this->userProvider = $userProvider; $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); } public function supports(Request $request): ?bool { - if (false === strpos($request->getRequestFormat() ?? '', 'json') && false === strpos($request->getContentType() ?? '', 'json')) { + if ( + !str_contains($request->getRequestFormat() ?? '', 'json') + && !str_contains($request->getContentTypeFormat() ?? '', 'json') + ) { return false; } @@ -81,28 +76,24 @@ public function supports(Request $request): ?bool return true; } - public function authenticate(Request $request): PassportInterface + public function authenticate(Request $request): Passport { try { - $credentials = $this->getCredentials($request); + $data = json_decode($request->getContent()); + if (!$data instanceof \stdClass) { + throw new BadRequestHttpException('Invalid JSON.'); + } + + $credentials = $this->getCredentials($data); } catch (BadRequestHttpException $e) { $request->setRequestFormat('json'); throw $e; } - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - $method = 'loadUserByIdentifier'; - if (!method_exists($this->userProvider, 'loadUserByIdentifier')) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); + $userBadge = new UserBadge($credentials['username'], $this->userProvider->loadUserByIdentifier(...)); + $passport = new Passport($userBadge, new PasswordCredentials($credentials['password']), [new RememberMeBadge((array) $data)]); - $method = 'loadUserByUsername'; - } - - $passport = new Passport( - new UserBadge($credentials['username'], [$this->userProvider, $method]), - new PasswordCredentials($credentials['password']) - ); if ($this->userProvider instanceof PasswordUpgraderInterface) { $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider)); } @@ -110,16 +101,6 @@ public function authenticate(Request $request): PassportInterface return $passport; } - /** - * @deprecated since Symfony 5.4, use {@link createToken()} instead - */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use "%s::createToken()" instead.', __METHOD__, __CLASS__); - - return $this->createToken($passport, $firewallName); - } - public function createToken(Passport $passport, string $firewallName): TokenInterface { return new UsernamePasswordToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); @@ -154,41 +135,33 @@ public function isInteractive(): bool return true; } - public function setTranslator(TranslatorInterface $translator) + public function setTranslator(TranslatorInterface $translator): void { $this->translator = $translator; } - private function getCredentials(Request $request) + private function getCredentials(\stdClass $data): array { - $data = json_decode($request->getContent()); - if (!$data instanceof \stdClass) { - throw new BadRequestHttpException('Invalid JSON.'); - } - $credentials = []; try { $credentials['username'] = $this->propertyAccessor->getValue($data, $this->options['username_path']); - if (!\is_string($credentials['username'])) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['username_path'])); - } - - if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { - throw new BadCredentialsException('Invalid username.'); + if (!\is_string($credentials['username']) || '' === $credentials['username']) { + throw new BadRequestHttpException(\sprintf('The key "%s" must be a non-empty string.', $this->options['username_path'])); } } catch (AccessException $e) { - throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['username_path']), $e); + throw new BadRequestHttpException(\sprintf('The key "%s" must be provided.', $this->options['username_path']), $e); } try { $credentials['password'] = $this->propertyAccessor->getValue($data, $this->options['password_path']); + $this->propertyAccessor->setValue($data, $this->options['password_path'], null); - if (!\is_string($credentials['password'])) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['password_path'])); + if (!\is_string($credentials['password']) || '' === $credentials['password']) { + throw new BadRequestHttpException(\sprintf('The key "%s" must be a non-empty string.', $this->options['password_path'])); } } catch (AccessException $e) { - throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['password_path']), $e); + throw new BadRequestHttpException(\sprintf('The key "%s" must be provided.', $this->options['password_path']), $e); } return $credentials; diff --git a/Authenticator/LoginLinkAuthenticator.php b/Authenticator/LoginLinkAuthenticator.php index 098349c8..1547b6e8 100644 --- a/Authenticator/LoginLinkAuthenticator.php +++ b/Authenticator/LoginLinkAuthenticator.php @@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkAuthenticationException; @@ -31,18 +31,15 @@ */ final class LoginLinkAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface { - private $loginLinkHandler; - private $httpUtils; - private $successHandler; - private $failureHandler; - private $options; + private array $options; - public function __construct(LoginLinkHandlerInterface $loginLinkHandler, HttpUtils $httpUtils, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options) - { - $this->loginLinkHandler = $loginLinkHandler; - $this->httpUtils = $httpUtils; - $this->successHandler = $successHandler; - $this->failureHandler = $failureHandler; + public function __construct( + private LoginLinkHandlerInterface $loginLinkHandler, + private HttpUtils $httpUtils, + private AuthenticationSuccessHandlerInterface $successHandler, + private AuthenticationFailureHandlerInterface $failureHandler, + array $options, + ) { $this->options = $options + ['check_post_only' => false]; } @@ -52,25 +49,23 @@ public function supports(Request $request): ?bool && $this->httpUtils->checkRequestPath($request, $this->options['check_route']); } - public function authenticate(Request $request): PassportInterface + public function authenticate(Request $request): Passport { - $username = $request->get('user'); - if (!$username) { + if (!$username = $request->get('user')) { throw new InvalidLoginLinkAuthenticationException('Missing user from link.'); } - return new SelfValidatingPassport( - new UserBadge($username, function () use ($request) { - try { - $user = $this->loginLinkHandler->consumeLoginLink($request); - } catch (InvalidLoginLinkExceptionInterface $e) { - throw new InvalidLoginLinkAuthenticationException('Login link could not be validated.', 0, $e); - } + $userBadge = new UserBadge($username, function () use ($request) { + try { + $user = $this->loginLinkHandler->consumeLoginLink($request); + } catch (InvalidLoginLinkExceptionInterface $e) { + throw new InvalidLoginLinkAuthenticationException('Login link could not be validated.', 0, $e); + } + + return $user; + }); - return $user; - }), - [new RememberMeBadge()] - ); + return new SelfValidatingPassport($userBadge, [new RememberMeBadge()]); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response diff --git a/Authenticator/Passport/Badge/CsrfTokenBadge.php b/Authenticator/Passport/Badge/CsrfTokenBadge.php index a4114a09..7c7b40f6 100644 --- a/Authenticator/Passport/Badge/CsrfTokenBadge.php +++ b/Authenticator/Passport/Badge/CsrfTokenBadge.php @@ -24,19 +24,17 @@ */ class CsrfTokenBadge implements BadgeInterface { - private $resolved = false; - private $csrfTokenId; - private $csrfToken; + private bool $resolved = false; /** * @param string $csrfTokenId An arbitrary string used to generate the value of the CSRF token. * Using a different string for each authenticator improves its security. * @param string|null $csrfToken The CSRF token presented in the request, if any */ - public function __construct(string $csrfTokenId, ?string $csrfToken) - { - $this->csrfTokenId = $csrfTokenId; - $this->csrfToken = $csrfToken; + public function __construct( + private string $csrfTokenId, + #[\SensitiveParameter] private ?string $csrfToken, + ) { } public function getCsrfTokenId(): string diff --git a/Authenticator/Passport/Badge/PasswordUpgradeBadge.php b/Authenticator/Passport/Badge/PasswordUpgradeBadge.php index 8870444a..9feb8c88 100644 --- a/Authenticator/Passport/Badge/PasswordUpgradeBadge.php +++ b/Authenticator/Passport/Badge/PasswordUpgradeBadge.php @@ -25,17 +25,17 @@ */ class PasswordUpgradeBadge implements BadgeInterface { - private $plaintextPassword; - private $passwordUpgrader; + private ?string $plaintextPassword = null; /** * @param string $plaintextPassword The presented password, used in the rehash * @param PasswordUpgraderInterface|null $passwordUpgrader The password upgrader, defaults to the UserProvider if null */ - public function __construct(string $plaintextPassword, ?PasswordUpgraderInterface $passwordUpgrader = null) - { + public function __construct( + #[\SensitiveParameter] string $plaintextPassword, + private ?PasswordUpgraderInterface $passwordUpgrader = null, + ) { $this->plaintextPassword = $plaintextPassword; - $this->passwordUpgrader = $passwordUpgrader; } public function getAndErasePlaintextPassword(): string diff --git a/Authenticator/Passport/Badge/RememberMeBadge.php b/Authenticator/Passport/Badge/RememberMeBadge.php index d961ef60..3b35ff44 100644 --- a/Authenticator/Passport/Badge/RememberMeBadge.php +++ b/Authenticator/Passport/Badge/RememberMeBadge.php @@ -26,7 +26,12 @@ */ class RememberMeBadge implements BadgeInterface { - private $enabled = false; + private bool $enabled = false; + + public function __construct( + public readonly array $parameters = [], + ) { + } /** * Enables remember-me cookie creation. @@ -37,7 +42,7 @@ class RememberMeBadge implements BadgeInterface * * @return $this */ - public function enable(): self + public function enable(): static { $this->enabled = true; @@ -52,7 +57,7 @@ public function enable(): self * * @return $this */ - public function disable(): self + public function disable(): static { $this->enabled = false; diff --git a/Authenticator/Passport/Badge/UserBadge.php b/Authenticator/Passport/Badge/UserBadge.php index 90f02865..d936ff62 100644 --- a/Authenticator/Passport/Badge/UserBadge.php +++ b/Authenticator/Passport/Badge/UserBadge.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\EventListener\UserProviderListener; @@ -27,9 +28,12 @@ */ class UserBadge implements BadgeInterface { - private $userIdentifier; + public const MAX_USERNAME_LENGTH = 4096; + + /** @var callable|null */ private $userLoader; - private $user; + private UserInterface $user; + private ?\Closure $identifierNormalizer = null; /** * Initializes the user badge. @@ -44,42 +48,71 @@ class UserBadge implements BadgeInterface * is thrown). If this is not set, the default user provider will be used with * $userIdentifier as username. */ - public function __construct(string $userIdentifier, ?callable $userLoader = null) - { - $this->userIdentifier = $userIdentifier; + public function __construct( + private string $userIdentifier, + ?callable $userLoader = null, + private ?array $attributes = null, + ?\Closure $identifierNormalizer = null, + ) { + if ('' === $userIdentifier) { + trigger_deprecation('symfony/security-http', '7.2', 'Using an empty string as user identifier is deprecated and will throw an exception in Symfony 8.0.'); + // throw new BadCredentialsException('Empty user identifier.'); + } + + if (\strlen($userIdentifier) > self::MAX_USERNAME_LENGTH) { + throw new BadCredentialsException('Username too long.'); + } + if ($identifierNormalizer) { + $this->identifierNormalizer = static fn () => $identifierNormalizer($userIdentifier); + } + $this->userLoader = $userLoader; } public function getUserIdentifier(): string { + if (isset($this->identifierNormalizer)) { + $this->userIdentifier = ($this->identifierNormalizer)(); + $this->identifierNormalizer = null; + } + return $this->userIdentifier; } + public function getAttributes(): ?array + { + return $this->attributes; + } + /** * @throws AuthenticationException when the user cannot be found */ public function getUser(): UserInterface { - if (null !== $this->user) { + if (isset($this->user)) { return $this->user; } if (null === $this->userLoader) { - throw new \LogicException(sprintf('No user loader is configured, did you forget to register the "%s" listener?', UserProviderListener::class)); + throw new \LogicException(\sprintf('No user loader is configured, did you forget to register the "%s" listener?', UserProviderListener::class)); } - $user = ($this->userLoader)($this->userIdentifier); + if (null === $this->getAttributes()) { + $user = ($this->userLoader)($this->getUserIdentifier()); + } else { + $user = ($this->userLoader)($this->getUserIdentifier(), $this->getAttributes()); + } // No user has been found via the $this->userLoader callback if (null === $user) { $exception = new UserNotFoundException(); - $exception->setUserIdentifier($this->userIdentifier); + $exception->setUserIdentifier($this->getUserIdentifier()); throw $exception; } if (!$user instanceof UserInterface) { - throw new AuthenticationServiceException(sprintf('The user provider must return a UserInterface object, "%s" given.', get_debug_type($user))); + throw new AuthenticationServiceException(\sprintf('The user provider must return a UserInterface object, "%s" given.', get_debug_type($user))); } return $this->user = $user; diff --git a/Authenticator/Passport/Credentials/CustomCredentials.php b/Authenticator/Passport/Credentials/CustomCredentials.php index 6dba8362..23ac8c7f 100644 --- a/Authenticator/Passport/Credentials/CustomCredentials.php +++ b/Authenticator/Passport/Credentials/CustomCredentials.php @@ -23,20 +23,19 @@ */ class CustomCredentials implements CredentialsInterface { - private $customCredentialsChecker; - private $credentials; - private $resolved = false; + private \Closure $customCredentialsChecker; + private bool $resolved = false; /** - * @param callable $customCredentialsChecker the check function. If this function does not return `true`, a - * BadCredentialsException is thrown. You may also throw a more - * specific exception in the function. - * @param mixed $credentials + * @param callable(mixed, UserInterface) $customCredentialsChecker If the callable does not return `true`, a + * BadCredentialsException is thrown. You may + * also throw a more specific exception. */ - public function __construct(callable $customCredentialsChecker, $credentials) - { - $this->customCredentialsChecker = $customCredentialsChecker; - $this->credentials = $credentials; + public function __construct( + callable $customCredentialsChecker, + private mixed $credentials, + ) { + $this->customCredentialsChecker = $customCredentialsChecker(...); } public function executeCustomChecker(UserInterface $user): void diff --git a/Authenticator/Passport/Credentials/PasswordCredentials.php b/Authenticator/Passport/Credentials/PasswordCredentials.php index 50d47fc7..9c86b129 100644 --- a/Authenticator/Passport/Credentials/PasswordCredentials.php +++ b/Authenticator/Passport/Credentials/PasswordCredentials.php @@ -25,10 +25,10 @@ */ class PasswordCredentials implements CredentialsInterface { - private $password; - private $resolved = false; + private ?string $password = null; + private bool $resolved = false; - public function __construct(string $password) + public function __construct(#[\SensitiveParameter] string $password) { $this->password = $password; } diff --git a/Authenticator/Passport/Passport.php b/Authenticator/Passport/Passport.php index 23bb33c9..27720806 100644 --- a/Authenticator/Passport/Passport.php +++ b/Authenticator/Passport/Passport.php @@ -25,15 +25,15 @@ * * @author Wouter de Jong */ -class Passport implements UserPassportInterface +class Passport { - protected $user; + protected UserInterface $user; - private $badges = []; - private $attributes = []; + private array $badges = []; + private array $attributes = []; /** - * @param CredentialsInterface $credentials the credentials to check for this authentication, use + * @param CredentialsInterface $credentials The credentials to check for this authentication, use * SelfValidatingPassport if no credentials should be checked * @param BadgeInterface[] $badges */ @@ -46,12 +46,9 @@ public function __construct(UserBadge $userBadge, CredentialsInterface $credenti } } - /** - * {@inheritdoc} - */ public function getUser(): UserInterface { - if (null === $this->user) { + if (!isset($this->user)) { if (!$this->hasBadge(UserBadge::class)) { throw new \LogicException('Cannot get the Security user, no username or UserBadge configured for this passport.'); } @@ -69,11 +66,17 @@ public function getUser(): UserInterface * This method replaces the current badge if it is already set on this * passport. * + * @param string|null $badgeFqcn A FQCN to which the badge should be mapped to. + * This allows replacing a built-in badge by a custom one using + * e.g. addBadge(new MyCustomUserBadge(), UserBadge::class) + * * @return $this */ - public function addBadge(BadgeInterface $badge): PassportInterface + public function addBadge(BadgeInterface $badge, ?string $badgeFqcn = null): static { - $this->badges[\get_class($badge)] = $badge; + $badgeFqcn ??= $badge::class; + + $this->badges[$badgeFqcn] = $badge; return $this; } @@ -83,6 +86,13 @@ public function hasBadge(string $badgeFqcn): bool return isset($this->badges[$badgeFqcn]); } + /** + * @template TBadge of BadgeInterface + * + * @param class-string $badgeFqcn + * + * @return TBadge|null + */ public function getBadge(string $badgeFqcn): ?BadgeInterface { return $this->badges[$badgeFqcn] ?? null; @@ -96,20 +106,12 @@ public function getBadges(): array return $this->badges; } - /** - * @param mixed $value - */ - public function setAttribute(string $name, $value): void + public function setAttribute(string $name, mixed $value): void { $this->attributes[$name] = $value; } - /** - * @param mixed $default - * - * @return mixed - */ - public function getAttribute(string $name, $default = null) + public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; } diff --git a/Authenticator/Passport/PassportInterface.php b/Authenticator/Passport/PassportInterface.php deleted file mode 100644 index 14198b80..00000000 --- a/Authenticator/Passport/PassportInterface.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Authenticator\Passport; - -use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; - -/** - * A Passport contains all security-related information that needs to be - * validated during authentication. - * - * A passport badge can be used to add any additional information to the - * passport. - * - * @author Wouter de Jong - * - * @deprecated since Symfony 5.4, use {@link Passport} instead - */ -interface PassportInterface -{ - /** - * Adds a new security badge. - * - * A passport can hold only one instance of the same security badge. - * This method replaces the current badge if it is already set on this - * passport. - * - * @return $this - */ - public function addBadge(BadgeInterface $badge): self; - - public function hasBadge(string $badgeFqcn): bool; - - public function getBadge(string $badgeFqcn): ?BadgeInterface; - - /** - * @return array, BadgeInterface> - */ - public function getBadges(): array; -} diff --git a/Authenticator/Passport/PassportTrait.php b/Authenticator/Passport/PassportTrait.php deleted file mode 100644 index 2a000145..00000000 --- a/Authenticator/Passport/PassportTrait.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Authenticator\Passport; - -use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" trait is deprecated, you must extend from "%s" instead.', PassportTrait::class, Passport::class); - -/** - * @author Wouter de Jong - * - * @deprecated since Symfony 5.4, use {@see Passport} instead - */ -trait PassportTrait -{ - private $badges = []; - - /** - * @return $this - */ - public function addBadge(BadgeInterface $badge): PassportInterface - { - $this->badges[\get_class($badge)] = $badge; - - return $this; - } - - public function hasBadge(string $badgeFqcn): bool - { - return isset($this->badges[$badgeFqcn]); - } - - public function getBadge(string $badgeFqcn): ?BadgeInterface - { - return $this->badges[$badgeFqcn] ?? null; - } - - /** - * @return array, BadgeInterface> - */ - public function getBadges(): array - { - return $this->badges; - } -} diff --git a/Authenticator/Passport/UserPassportInterface.php b/Authenticator/Passport/UserPassportInterface.php deleted file mode 100644 index 319c2952..00000000 --- a/Authenticator/Passport/UserPassportInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Authenticator\Passport; - -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\User\UserInterface; - -/** - * Represents a passport for a Security User. - * - * @author Wouter de Jong - * - * @deprecated since Symfony 5.4, use {@link Passport} instead - */ -interface UserPassportInterface extends PassportInterface -{ - /** - * @throws AuthenticationException when the user cannot be found - */ - public function getUser(): UserInterface; -} diff --git a/Authenticator/RememberMeAuthenticator.php b/Authenticator/RememberMeAuthenticator.php index e5148159..c695be08 100644 --- a/Authenticator/RememberMeAuthenticator.php +++ b/Authenticator/RememberMeAuthenticator.php @@ -23,7 +23,6 @@ use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; use Symfony\Component\Security\Http\RememberMe\RememberMeHandlerInterface; @@ -44,16 +43,31 @@ */ class RememberMeAuthenticator implements InteractiveAuthenticatorInterface { - private $rememberMeHandler; - private $secret; - private $tokenStorage; - private $cookieName; - private $logger; + private string $secret; + private TokenStorageInterface $tokenStorage; + private string $cookieName; + private ?LoggerInterface $logger; + + /** + * @param TokenStorageInterface $tokenStorage + * @param string $cookieName + * @param ?LoggerInterface $logger + */ + public function __construct( + private RememberMeHandlerInterface $rememberMeHandler, + #[\SensitiveParameter] TokenStorageInterface|string $tokenStorage, + string|TokenStorageInterface $cookieName, + LoggerInterface|string|null $logger = null, + ) { + if (\is_string($tokenStorage)) { + trigger_deprecation('symfony/security-http', '7.2', 'The "$secret" argument of "%s()" is deprecated.', __METHOD__); + + $this->secret = $tokenStorage; + $tokenStorage = $cookieName; + $cookieName = $logger; + $logger = \func_num_args() > 4 ? func_get_arg(4) : null; + } - public function __construct(RememberMeHandlerInterface $rememberMeHandler, string $secret, TokenStorageInterface $tokenStorage, string $cookieName, ?LoggerInterface $logger = null) - { - $this->rememberMeHandler = $rememberMeHandler; - $this->secret = $secret; $this->tokenStorage = $tokenStorage; $this->cookieName = $cookieName; $this->logger = $logger; @@ -74,41 +88,32 @@ public function supports(Request $request): ?bool return false; } - if (null !== $this->logger) { - $this->logger->debug('Remember-me cookie detected.'); - } + $this->logger?->debug('Remember-me cookie detected.'); // the `null` return value indicates that this authenticator supports lazy firewalls return null; } - public function authenticate(Request $request): PassportInterface + public function authenticate(Request $request): Passport { - $rawCookie = $request->cookies->get($this->cookieName); - if (!$rawCookie) { + if (!$rawCookie = $request->cookies->get($this->cookieName)) { throw new \LogicException('No remember-me cookie is found.'); } $rememberMeCookie = RememberMeDetails::fromRawCookie($rawCookie); - return new SelfValidatingPassport(new UserBadge($rememberMeCookie->getUserIdentifier(), function () use ($rememberMeCookie) { - return $this->rememberMeHandler->consumeRememberMeCookie($rememberMeCookie); - })); - } + $userBadge = new UserBadge($rememberMeCookie->getUserIdentifier(), fn () => $this->rememberMeHandler->consumeRememberMeCookie($rememberMeCookie)); - /** - * @deprecated since Symfony 5.4, use {@link createToken()} instead - */ - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use "%s::createToken()" instead.', __METHOD__, __CLASS__); - - return $this->createToken($passport, $firewallName); + return new SelfValidatingPassport($userBadge); } public function createToken(Passport $passport, string $firewallName): TokenInterface { - return new RememberMeToken($passport->getUser(), $firewallName, $this->secret); + if (isset($this->secret)) { + return new RememberMeToken($passport->getUser(), $firewallName, $this->secret); + } + + return new RememberMeToken($passport->getUser(), $firewallName); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response diff --git a/Authenticator/RemoteUserAuthenticator.php b/Authenticator/RemoteUserAuthenticator.php index 140b6c27..824f2c37 100644 --- a/Authenticator/RemoteUserAuthenticator.php +++ b/Authenticator/RemoteUserAuthenticator.php @@ -24,27 +24,26 @@ * @author Fabien Potencier * @author Maxime Douailin * - * @final - * - * @internal in Symfony 5.1 + * @internal */ -class RemoteUserAuthenticator extends AbstractPreAuthenticatedAuthenticator +final class RemoteUserAuthenticator extends AbstractPreAuthenticatedAuthenticator { - private $userKey; - - public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, string $userKey = 'REMOTE_USER', ?LoggerInterface $logger = null) - { + public function __construct( + UserProviderInterface $userProvider, + TokenStorageInterface $tokenStorage, + string $firewallName, + private string $userKey = 'REMOTE_USER', + ?LoggerInterface $logger = null, + ) { parent::__construct($userProvider, $tokenStorage, $firewallName, $logger); - - $this->userKey = $userKey; } protected function extractUsername(Request $request): ?string { if (!$request->server->has($this->userKey)) { - throw new BadCredentialsException(sprintf('User key was not found: "%s".', $this->userKey)); + throw new BadCredentialsException(\sprintf('User key was not found: "%s".', $this->userKey)); } - return $request->server->get($this->userKey); + return $request->server->get($this->userKey) ?: null; } } diff --git a/Authenticator/Token/PostAuthenticationToken.php b/Authenticator/Token/PostAuthenticationToken.php index 6bbec6f3..cba6c730 100644 --- a/Authenticator/Token/PostAuthenticationToken.php +++ b/Authenticator/Token/PostAuthenticationToken.php @@ -16,15 +16,16 @@ class PostAuthenticationToken extends AbstractToken { - private $firewallName; - /** * @param string[] $roles An array of roles * * @throws \InvalidArgumentException */ - public function __construct(UserInterface $user, string $firewallName, array $roles) - { + public function __construct( + UserInterface $user, + private string $firewallName, + array $roles, + ) { parent::__construct($roles); if ('' === $firewallName) { @@ -32,22 +33,13 @@ public function __construct(UserInterface $user, string $firewallName, array $ro } $this->setUser($user); - $this->firewallName = $firewallName; - - // @deprecated since Symfony 5.4 - if (method_exists($this, 'setAuthenticated')) { - // this token is meant to be used after authentication success, so it is always authenticated - $this->setAuthenticated(true, false); - } } /** * This is meant to be only a token, where credentials * have already been used and are thus cleared. - * - * {@inheritdoc} */ - public function getCredentials() + public function getCredentials(): mixed { return []; } @@ -57,17 +49,11 @@ public function getFirewallName(): string return $this->firewallName; } - /** - * {@inheritdoc} - */ public function __serialize(): array { return [$this->firewallName, parent::__serialize()]; } - /** - * {@inheritdoc} - */ public function __unserialize(array $data): void { [$this->firewallName, $parentData] = $data; diff --git a/Authenticator/X509Authenticator.php b/Authenticator/X509Authenticator.php index 8f30a239..252cb560 100644 --- a/Authenticator/X509Authenticator.php +++ b/Authenticator/X509Authenticator.php @@ -28,15 +28,16 @@ */ class X509Authenticator extends AbstractPreAuthenticatedAuthenticator { - private $userKey; - private $credentialsKey; - - public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, string $userKey = 'SSL_CLIENT_S_DN_Email', string $credentialsKey = 'SSL_CLIENT_S_DN', ?LoggerInterface $logger = null) - { + public function __construct( + UserProviderInterface $userProvider, + TokenStorageInterface $tokenStorage, + string $firewallName, + private string $userKey = 'SSL_CLIENT_S_DN_Email', + private string $credentialsKey = 'SSL_CLIENT_S_DN', + ?LoggerInterface $logger = null, + private string $credentialUserIdentifier = 'emailAddress', + ) { parent::__construct($userProvider, $tokenStorage, $firewallName, $logger); - - $this->userKey = $userKey; - $this->credentialsKey = $credentialsKey; } protected function extractUsername(Request $request): string @@ -46,13 +47,13 @@ protected function extractUsername(Request $request): string $username = $request->server->get($this->userKey); } elseif ( $request->server->has($this->credentialsKey) - && preg_match('#emailAddress=([^,/@]++@[^,/]++)#', $request->server->get($this->credentialsKey), $matches) + && preg_match('#'.preg_quote($this->credentialUserIdentifier, '#').'=([^,/]++)#', $request->server->get($this->credentialsKey), $matches) ) { - $username = $matches[1]; + $username = trim($matches[1]); } if (null === $username) { - throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey)); + throw new BadCredentialsException(\sprintf('SSL credentials not found: "%s", "%s".', $this->userKey, $this->credentialsKey)); } return $username; diff --git a/Authorization/AccessDeniedHandlerInterface.php b/Authorization/AccessDeniedHandlerInterface.php index 871c877f..bd5e818f 100644 --- a/Authorization/AccessDeniedHandlerInterface.php +++ b/Authorization/AccessDeniedHandlerInterface.php @@ -25,8 +25,6 @@ interface AccessDeniedHandlerInterface { /** * Handles an access denied failure. - * - * @return Response|null */ - public function handle(Request $request, AccessDeniedException $accessDeniedException); + public function handle(Request $request, AccessDeniedException $accessDeniedException): ?Response; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 10710157..275180ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,75 @@ CHANGELOG ========= +7.3 +--- + + * Add encryption support to `OidcTokenHandler` (JWE) + * Replace `$hideAccountStatusExceptions` argument with `$exposeSecurityErrors` in `AuthenticatorManager` constructor + * Add argument `$identifierNormalizer` to `UserBadge::__construct()` to allow normalizing the identifier + * Support hashing the hashed password using crc32c when putting the user in the session + * Add support for closures in `#[IsGranted]` + * Add `OAuth2TokenHandler` with OAuth2 Token Introspection support for `AccessTokenAuthenticator` + * Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler` + +7.2 +--- + + * Pass the current token to the `checkPostAuth()` method of user checkers + * Deprecate argument `$secret` of `RememberMeAuthenticator` + * Deprecate passing an empty string as `$userIdentifier` argument to `UserBadge` constructor + * Allow passing passport attributes to the `UserAuthenticatorInterface::authenticateUser()` method + +7.1 +--- + + * Add `#[IsCsrfTokenValid]` attribute + * Add CAS 2.0 access token handler + * Make empty username or empty password on form login attempts throw `BadCredentialsException` + +7.0 +--- + + * Add argument `$badgeFqcn` to `Passport::addBadge()` + * Add argument `$lifetime` to `LoginLinkHandlerInterface::createLoginLink()` + * Throw when calling the constructor of `DefaultLoginRateLimiter` with an empty secret + +6.4 +--- + + * `UserValueResolver` no longer implements `ArgumentValueResolverInterface` + * Deprecate calling the constructor of `DefaultLoginRateLimiter` with an empty secret + +6.3 +--- + + * Add `RememberMeBadge` to `JsonLoginAuthenticator` and enable reading parameter in JSON request body + * Add argument `$exceptionCode` to `#[IsGranted]` + * Deprecate passing a secret as the 2nd argument to the constructor of `Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler` + * Add `OidcUserInfoTokenHandler` and `OidcTokenHandler` with OIDC support for `AccessTokenAuthenticator` + * Add `attributes` optional array argument in `UserBadge` + * Call `UserBadge::userLoader` with attributes if the argument is set + * Allow to override badge fqcn on `Passport::addBadge` + * Add `SecurityTokenValueResolver` to inject token as controller argument + +6.2 +--- + + * Add maximum username length enforcement of 4096 characters in `UserBadge` + * Add `#[IsGranted()]` + * Deprecate empty username or password when using when using `JsonLoginAuthenticator` + * Set custom lifetime for login link + * Add `$lifetime` parameter to `LoginLinkHandlerInterface::createLoginLink()` + * Add RFC6750 Access Token support to allow token-based authentication + * Allow using expressions as `#[IsGranted()]` attribute and subject + +6.0 +--- + + * Remove `LogoutSuccessHandlerInterface` and `LogoutHandlerInterface`, register a listener on the `LogoutEvent` event instead + * Remove `CookieClearingLogoutHandler`, `SessionLogoutHandler` and `CsrfTokenClearingLogoutHandler`. + Use `CookieClearingLogoutListener`, `SessionLogoutListener` and `CsrfTokenClearingLogoutListener` instead + 5.4 --- diff --git a/Controller/SecurityTokenValueResolver.php b/Controller/SecurityTokenValueResolver.php new file mode 100644 index 00000000..23c482b1 --- /dev/null +++ b/Controller/SecurityTokenValueResolver.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Controller; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +/** + * @author Konstantin Myakshin + */ +final class SecurityTokenValueResolver implements ValueResolverInterface +{ + public function __construct(private readonly TokenStorageInterface $tokenStorage) + { + } + + /** + * @return TokenInterface[] + */ + public function resolve(Request $request, ArgumentMetadata $argument): array + { + if (!($type = $argument->getType()) || (TokenInterface::class !== $type && !is_subclass_of($type, TokenInterface::class))) { + return []; + } + + if (null !== $token = $this->tokenStorage->getToken()) { + return [$token]; + } + + if ($argument->isNullable()) { + return []; + } + + throw new HttpException(Response::HTTP_UNAUTHORIZED, 'A security token is required but the token storage is empty.'); + } +} diff --git a/Controller/UserValueResolver.php b/Controller/UserValueResolver.php index 9d10f328..f64c167f 100644 --- a/Controller/UserValueResolver.php +++ b/Controller/UserValueResolver.php @@ -12,10 +12,10 @@ namespace Symfony\Component\Security\Http\Controller; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\CurrentUser; @@ -24,37 +24,39 @@ * * @author Iltar van der Berg */ -final class UserValueResolver implements ArgumentValueResolverInterface +final class UserValueResolver implements ValueResolverInterface { - private $tokenStorage; - - public function __construct(TokenStorageInterface $tokenStorage) - { - $this->tokenStorage = $tokenStorage; + public function __construct( + private TokenStorageInterface $tokenStorage, + ) { } - public function supports(Request $request, ArgumentMetadata $argument): bool + public function resolve(Request $request, ArgumentMetadata $argument): array { // with the attribute, the type can be any UserInterface implementation // otherwise, the type must be UserInterface - if (UserInterface::class !== $argument->getType() && !$argument->getAttributes(CurrentUser::class, ArgumentMetadata::IS_INSTANCEOF)) { - return false; + if (UserInterface::class !== $argument->getType() && !$argument->getAttributesOfType(CurrentUser::class, ArgumentMetadata::IS_INSTANCEOF)) { + return []; } - $token = $this->tokenStorage->getToken(); - if (!$token instanceof TokenInterface) { - return false; - } + if (null === $user = $this->tokenStorage->getToken()?->getUser()) { + // if no user is present but a default value exists we use it to prevent the EntityValueResolver or others + // from attempting resolution of the User as the current logged in user was requested here + if ($argument->hasDefaultValue()) { + return [$argument->getDefaultValue()]; + } - $user = $token->getUser(); + if (!$argument->isNullable()) { + throw new AccessDeniedException(\sprintf('There is no logged-in user to pass to $%s, make the argument nullable if you want to allow anonymous access to the action.', $argument->getName())); + } - // in case it's not an object we cannot do anything with it; E.g. "anon." - // @deprecated since 5.4 - return $user instanceof UserInterface; - } + return [null]; + } - public function resolve(Request $request, ArgumentMetadata $argument): iterable - { - yield $this->tokenStorage->getToken()->getUser(); + if (null === $argument->getType() || $user instanceof ($argument->getType())) { + return [$user]; + } + + throw new AccessDeniedException(\sprintf('The logged-in user is an instance of "%s" but a user of type "%s" is expected.', $user::class, $argument->getType())); } } diff --git a/EntryPoint/AuthenticationEntryPointInterface.php b/EntryPoint/AuthenticationEntryPointInterface.php index 5e5be9ab..d02b78ef 100644 --- a/EntryPoint/AuthenticationEntryPointInterface.php +++ b/EntryPoint/AuthenticationEntryPointInterface.php @@ -39,8 +39,6 @@ interface AuthenticationEntryPointInterface * - For an API token authentication system, you return a 401 response * * return new Response('Auth header required', 401); - * - * @return Response */ - public function start(Request $request, ?AuthenticationException $authException = null); + public function start(Request $request, ?AuthenticationException $authException = null): Response; } diff --git a/EntryPoint/BasicAuthenticationEntryPoint.php b/EntryPoint/BasicAuthenticationEntryPoint.php deleted file mode 100644 index e658ed9e..00000000 --- a/EntryPoint/BasicAuthenticationEntryPoint.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\EntryPoint; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use the new security system with "%s" instead.', BasicAuthenticationEntryPoint::class, HttpBasicAuthenticator::class); - -/** - * BasicAuthenticationEntryPoint starts an HTTP Basic authentication. - * - * @author Fabien Potencier - * - * @deprecated since Symfony 5.4 - */ -class BasicAuthenticationEntryPoint implements AuthenticationEntryPointInterface -{ - private $realmName; - - public function __construct(string $realmName) - { - $this->realmName = $realmName; - } - - /** - * {@inheritdoc} - */ - public function start(Request $request, ?AuthenticationException $authException = null) - { - $response = new Response(); - $response->headers->set('WWW-Authenticate', sprintf('Basic realm="%s"', $this->realmName)); - $response->setStatusCode(401); - - return $response; - } -} diff --git a/EntryPoint/Exception/NotAnEntryPointException.php b/EntryPoint/Exception/NotAnEntryPointException.php index e421dcf0..80a6fb6e 100644 --- a/EntryPoint/Exception/NotAnEntryPointException.php +++ b/EntryPoint/Exception/NotAnEntryPointException.php @@ -11,12 +11,15 @@ namespace Symfony\Component\Security\Http\EntryPoint\Exception; +use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; + /** * Thrown by generic decorators when a decorated authenticator does not implement * {@see AuthenticationEntryPointInterface}. * * @author Robin Chalas */ +#[WithHttpStatus(401)] class NotAnEntryPointException extends \RuntimeException { } diff --git a/EntryPoint/FormAuthenticationEntryPoint.php b/EntryPoint/FormAuthenticationEntryPoint.php deleted file mode 100644 index ca4dba5c..00000000 --- a/EntryPoint/FormAuthenticationEntryPoint.php +++ /dev/null @@ -1,66 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\EntryPoint; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; -use Symfony\Component\Security\Http\HttpUtils; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use the new security system with "%s" instead.', FormAuthenticationEntryPoint::class, FormLoginAuthenticator::class); - -/** - * FormAuthenticationEntryPoint starts an authentication via a login form. - * - * @author Fabien Potencier - * - * @deprecated since Symfony 5.4 - */ -class FormAuthenticationEntryPoint implements AuthenticationEntryPointInterface -{ - private $loginPath; - private $useForward; - private $httpKernel; - private $httpUtils; - - /** - * @param string $loginPath The path to the login form - * @param bool $useForward Whether to forward or redirect to the login form - */ - public function __construct(HttpKernelInterface $kernel, HttpUtils $httpUtils, string $loginPath, bool $useForward = false) - { - $this->httpKernel = $kernel; - $this->httpUtils = $httpUtils; - $this->loginPath = $loginPath; - $this->useForward = $useForward; - } - - /** - * {@inheritdoc} - */ - public function start(Request $request, ?AuthenticationException $authException = null) - { - if ($this->useForward) { - $subRequest = $this->httpUtils->createRequest($request, $this->loginPath); - - $response = $this->httpKernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); - if (200 === $response->getStatusCode()) { - $response->setStatusCode(401); - } - - return $response; - } - - return $this->httpUtils->createRedirectResponse($request, $this->loginPath); - } -} diff --git a/EntryPoint/RetryAuthenticationEntryPoint.php b/EntryPoint/RetryAuthenticationEntryPoint.php deleted file mode 100644 index 0a31f5a4..00000000 --- a/EntryPoint/RetryAuthenticationEntryPoint.php +++ /dev/null @@ -1,64 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\EntryPoint; - -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Firewall\ChannelListener; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" directly (and optionally configure the HTTP(s) ports there).', RetryAuthenticationEntryPoint::class, ChannelListener::class); - -/** - * RetryAuthenticationEntryPoint redirects URL based on the configured scheme. - * - * This entry point is not intended to work with HTTP post requests. - * - * @author Fabien Potencier - * - * @deprecated since Symfony 5.4 - */ -class RetryAuthenticationEntryPoint implements AuthenticationEntryPointInterface -{ - private $httpPort; - private $httpsPort; - - public function __construct(int $httpPort = 80, int $httpsPort = 443) - { - $this->httpPort = $httpPort; - $this->httpsPort = $httpsPort; - } - - /** - * {@inheritdoc} - */ - public function start(Request $request, ?AuthenticationException $authException = null) - { - $scheme = $request->isSecure() ? 'http' : 'https'; - if ('http' === $scheme && 80 != $this->httpPort) { - $port = ':'.$this->httpPort; - } elseif ('https' === $scheme && 443 != $this->httpsPort) { - $port = ':'.$this->httpsPort; - } else { - $port = ''; - } - - $qs = $request->getQueryString(); - if (null !== $qs) { - $qs = '?'.$qs; - } - - $url = $scheme.'://'.$request->getHost().$port.$request->getBaseUrl().$request->getPathInfo().$qs; - - return new RedirectResponse($url, 301); - } -} diff --git a/Event/AuthenticationTokenCreatedEvent.php b/Event/AuthenticationTokenCreatedEvent.php index 632f3ec8..cc6386ce 100644 --- a/Event/AuthenticationTokenCreatedEvent.php +++ b/Event/AuthenticationTokenCreatedEvent.php @@ -13,7 +13,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -23,33 +22,23 @@ */ class AuthenticationTokenCreatedEvent extends Event { - private $authenticatedToken; - private $passport; - - /** - * @param Passport $passport - */ - public function __construct(TokenInterface $token, PassportInterface $passport) - { - if (!$passport instanceof Passport) { - trigger_deprecation('symfony/security-http', '5.4', 'Not passing an instance of "%s" as "$passport" argument of "%s()" is deprecated, "%s" given.', Passport::class, __METHOD__, get_debug_type($passport)); - } - - $this->authenticatedToken = $token; - $this->passport = $passport; + public function __construct( + private TokenInterface $token, + private Passport $passport, + ) { } public function getAuthenticatedToken(): TokenInterface { - return $this->authenticatedToken; + return $this->token; } public function setAuthenticatedToken(TokenInterface $authenticatedToken): void { - $this->authenticatedToken = $authenticatedToken; + $this->token = $authenticatedToken; } - public function getPassport(): PassportInterface + public function getPassport(): Passport { return $this->passport; } diff --git a/Event/CheckPassportEvent.php b/Event/CheckPassportEvent.php index a3fe109b..0428d7df 100644 --- a/Event/CheckPassportEvent.php +++ b/Event/CheckPassportEvent.php @@ -12,8 +12,8 @@ namespace Symfony\Component\Security\Http\Event; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -27,28 +27,18 @@ */ class CheckPassportEvent extends Event { - private $authenticator; - private $passport; - - /** - * @param Passport $passport - */ - public function __construct(AuthenticatorInterface $authenticator, PassportInterface $passport) - { - if (!$passport instanceof Passport) { - trigger_deprecation('symfony/security-http', '5.4', 'Not passing an instance of "%s" as "$passport" argument of "%s()" is deprecated, "%s" given.', Passport::class, __METHOD__, get_debug_type($passport)); - } - - $this->authenticator = $authenticator; - $this->passport = $passport; + public function __construct( + private AuthenticatorInterface $authenticator, + private Passport $passport, + ) { } public function getAuthenticator(): AuthenticatorInterface { - return $this->authenticator; + return $this->authenticator instanceof TraceableAuthenticator ? $this->authenticator->getAuthenticator() : $this->authenticator; } - public function getPassport(): PassportInterface + public function getPassport(): Passport { return $this->passport; } diff --git a/Event/DeauthenticatedEvent.php b/Event/DeauthenticatedEvent.php deleted file mode 100644 index f064ccea..00000000 --- a/Event/DeauthenticatedEvent.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Event; - -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Contracts\EventDispatcher\Event; - -/** - * Deauthentication happens in case the user has changed when trying to - * refresh the token. - * - * Use {@see TokenDeauthenticatedEvent} if you want to cover all cases where - * a session is deauthenticated. - * - * @author Hamza Amrouche - * - * @deprecated since Symfony 5.4, use TokenDeauthenticatedEvent instead - */ -final class DeauthenticatedEvent extends Event -{ - private $originalToken; - private $refreshedToken; - - public function __construct(TokenInterface $originalToken, TokenInterface $refreshedToken, bool $triggerDeprecation = true) - { - if ($triggerDeprecation) { - @trigger_deprecation('symfony/security-http', '5.4', 'Class "%s" is deprecated, use "%s" instead.', __CLASS__, TokenDeauthenticatedEvent::class); - } - - $this->originalToken = $originalToken; - $this->refreshedToken = $refreshedToken; - } - - public function getRefreshedToken(): TokenInterface - { - @trigger_deprecation('symfony/security-http', '5.4', 'Class "%s" is deprecated, use "%s" instead.', __CLASS__, TokenDeauthenticatedEvent::class); - - return $this->refreshedToken; - } - - public function getOriginalToken(): TokenInterface - { - @trigger_deprecation('symfony/security-http', '5.4', 'Class "%s" is deprecated, use "%s" instead.', __CLASS__, TokenDeauthenticatedEvent::class); - - return $this->originalToken; - } -} diff --git a/Event/InteractiveLoginEvent.php b/Event/InteractiveLoginEvent.php index 3ba98634..0f394d65 100644 --- a/Event/InteractiveLoginEvent.php +++ b/Event/InteractiveLoginEvent.php @@ -20,13 +20,10 @@ */ final class InteractiveLoginEvent extends Event { - private $request; - private $authenticationToken; - - public function __construct(Request $request, TokenInterface $authenticationToken) - { - $this->request = $request; - $this->authenticationToken = $authenticationToken; + public function __construct( + private Request $request, + private TokenInterface $authenticationToken, + ) { } public function getRequest(): Request diff --git a/Event/LazyResponseEvent.php b/Event/LazyResponseEvent.php index 319be376..117cee4e 100644 --- a/Event/LazyResponseEvent.php +++ b/Event/LazyResponseEvent.php @@ -24,17 +24,12 @@ */ final class LazyResponseEvent extends RequestEvent { - private $event; - - public function __construct(parent $event) - { - $this->event = $event; + public function __construct( + private parent $event, + ) { } - /** - * {@inheritdoc} - */ - public function setResponse(Response $response) + public function setResponse(Response $response): never { $this->stopPropagation(); $this->event->stopPropagation(); @@ -42,45 +37,23 @@ public function setResponse(Response $response) throw new LazyResponseException($response); } - /** - * {@inheritdoc} - */ public function getKernel(): HttpKernelInterface { return $this->event->getKernel(); } - /** - * {@inheritdoc} - */ public function getRequest(): Request { return $this->event->getRequest(); } - /** - * {@inheritdoc} - */ public function getRequestType(): int { return $this->event->getRequestType(); } - /** - * {@inheritdoc} - */ public function isMainRequest(): bool { return $this->event->isMainRequest(); } - - /** - * {@inheritdoc} - */ - public function isMasterRequest(): bool - { - trigger_deprecation('symfony/security-http', '5.3', '"%s()" is deprecated, use "isMainRequest()" instead.', __METHOD__); - - return $this->event->isMainRequest(); - } } diff --git a/Event/LoginFailureEvent.php b/Event/LoginFailureEvent.php index e058ced6..3f1485e5 100644 --- a/Event/LoginFailureEvent.php +++ b/Event/LoginFailureEvent.php @@ -15,8 +15,8 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -29,28 +29,14 @@ */ class LoginFailureEvent extends Event { - private $exception; - private $authenticator; - private $request; - private $response; - private $firewallName; - private $passport; - - /** - * @param Passport|null $passport - */ - public function __construct(AuthenticationException $exception, AuthenticatorInterface $authenticator, Request $request, ?Response $response, string $firewallName, ?PassportInterface $passport = null) - { - if (null !== $passport && !$passport instanceof Passport) { - trigger_deprecation('symfony/security-http', '5.4', 'Not passing an instance of "%s" or "null" as "$passport" argument of "%s()" is deprecated, "%s" given.', Passport::class, __METHOD__, get_debug_type($passport)); - } - - $this->exception = $exception; - $this->authenticator = $authenticator; - $this->request = $request; - $this->response = $response; - $this->firewallName = $firewallName; - $this->passport = $passport; + public function __construct( + private AuthenticationException $exception, + private AuthenticatorInterface $authenticator, + private Request $request, + private ?Response $response, + private string $firewallName, + private ?Passport $passport = null, + ) { } public function getException(): AuthenticationException @@ -60,7 +46,7 @@ public function getException(): AuthenticationException public function getAuthenticator(): AuthenticatorInterface { - return $this->authenticator; + return $this->authenticator instanceof TraceableAuthenticator ? $this->authenticator->getAuthenticator() : $this->authenticator; } public function getFirewallName(): string @@ -73,7 +59,7 @@ public function getRequest(): Request return $this->request; } - public function setResponse(?Response $response) + public function setResponse(?Response $response): void { $this->response = $response; } @@ -83,7 +69,7 @@ public function getResponse(): ?Response return $this->response; } - public function getPassport(): ?PassportInterface + public function getPassport(): ?Passport { return $this->passport; } diff --git a/Event/LoginSuccessEvent.php b/Event/LoginSuccessEvent.php index ee68de9a..1a410287 100644 --- a/Event/LoginSuccessEvent.php +++ b/Event/LoginSuccessEvent.php @@ -14,12 +14,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; -use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -34,49 +32,29 @@ */ class LoginSuccessEvent extends Event { - private $authenticator; - private $passport; - private $authenticatedToken; - private $previousToken; - private $request; - private $response; - private $firewallName; - - /** - * @param Passport $passport - */ - public function __construct(AuthenticatorInterface $authenticator, PassportInterface $passport, TokenInterface $authenticatedToken, Request $request, ?Response $response, string $firewallName, ?TokenInterface $previousToken = null) - { - if (!$passport instanceof Passport) { - trigger_deprecation('symfony/security-http', '5.4', 'Not passing an instance of "%s" as "$passport" argument of "%s()" is deprecated, "%s" given.', Passport::class, __METHOD__, get_debug_type($passport)); - } - - $this->authenticator = $authenticator; - $this->passport = $passport; - $this->authenticatedToken = $authenticatedToken; - $this->previousToken = $previousToken; - $this->request = $request; - $this->response = $response; - $this->firewallName = $firewallName; + public function __construct( + private AuthenticatorInterface $authenticator, + private Passport $passport, + private TokenInterface $authenticatedToken, + private Request $request, + private ?Response $response, + private string $firewallName, + private ?TokenInterface $previousToken = null, + ) { } public function getAuthenticator(): AuthenticatorInterface { - return $this->authenticator; + return $this->authenticator instanceof TraceableAuthenticator ? $this->authenticator->getAuthenticator() : $this->authenticator; } - public function getPassport(): PassportInterface + public function getPassport(): Passport { return $this->passport; } public function getUser(): UserInterface { - // @deprecated since Symfony 5.4, passport will always have a user in 6.0 - if (!$this->passport instanceof UserPassportInterface) { - throw new LogicException(sprintf('Cannot call "%s" as the authenticator ("%s") did not set a user.', __METHOD__, \get_class($this->authenticator))); - } - return $this->passport->getUser(); } diff --git a/Event/LogoutEvent.php b/Event/LogoutEvent.php index 3c521f1c..d218a429 100644 --- a/Event/LogoutEvent.php +++ b/Event/LogoutEvent.php @@ -21,14 +21,12 @@ */ class LogoutEvent extends Event { - private $request; - private $response; - private $token; + private ?Response $response = null; - public function __construct(Request $request, ?TokenInterface $token) - { - $this->request = $request; - $this->token = $token; + public function __construct( + private Request $request, + private ?TokenInterface $token, + ) { } public function getRequest(): Request diff --git a/Event/SwitchUserEvent.php b/Event/SwitchUserEvent.php index e1f1bd08..3a739394 100644 --- a/Event/SwitchUserEvent.php +++ b/Event/SwitchUserEvent.php @@ -23,15 +23,11 @@ */ final class SwitchUserEvent extends Event { - private $request; - private $targetUser; - private $token; - - public function __construct(Request $request, UserInterface $targetUser, ?TokenInterface $token = null) - { - $this->request = $request; - $this->targetUser = $targetUser; - $this->token = $token; + public function __construct( + private Request $request, + private UserInterface $targetUser, + private ?TokenInterface $token = null, + ) { } public function getRequest(): Request @@ -49,7 +45,7 @@ public function getToken(): ?TokenInterface return $this->token; } - public function setToken(TokenInterface $token) + public function setToken(TokenInterface $token): void { $this->token = $token; } diff --git a/Event/TokenDeauthenticatedEvent.php b/Event/TokenDeauthenticatedEvent.php index b09f4ec1..e453e4f0 100644 --- a/Event/TokenDeauthenticatedEvent.php +++ b/Event/TokenDeauthenticatedEvent.php @@ -30,13 +30,10 @@ */ final class TokenDeauthenticatedEvent extends Event { - private $originalToken; - private $request; - - public function __construct(TokenInterface $originalToken, Request $request) - { - $this->originalToken = $originalToken; - $this->request = $request; + public function __construct( + private TokenInterface $originalToken, + private Request $request, + ) { } public function getOriginalToken(): TokenInterface diff --git a/EventListener/CheckCredentialsListener.php b/EventListener/CheckCredentialsListener.php index 812419d6..8e887709 100644 --- a/EventListener/CheckCredentialsListener.php +++ b/EventListener/CheckCredentialsListener.php @@ -13,14 +13,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; -use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\User\LegacyPasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; -use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\CheckPassportEvent; /** @@ -33,29 +31,20 @@ */ class CheckCredentialsListener implements EventSubscriberInterface { - private $hasherFactory; - - /** - * @param PasswordHasherFactoryInterface $hasherFactory - */ - public function __construct($hasherFactory) - { - if ($hasherFactory instanceof EncoderFactoryInterface) { - trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); - } - - $this->hasherFactory = $hasherFactory; + public function __construct( + private PasswordHasherFactoryInterface $hasherFactory, + ) { } public function checkPassport(CheckPassportEvent $event): void { $passport = $event->getPassport(); - if ($passport instanceof UserPassportInterface && $passport->hasBadge(PasswordCredentials::class)) { + if ($passport->hasBadge(PasswordCredentials::class)) { // Use the password hasher to validate the credentials $user = $passport->getUser(); if (!$user instanceof PasswordAuthenticatedUserInterface) { - trigger_deprecation('symfony/security-http', '5.3', 'Not implementing the "%s" interface in class "%s" while using password-based authentication is deprecated.', PasswordAuthenticatedUserInterface::class, get_debug_type($user)); + throw new \LogicException(\sprintf('Class "%s" must implement "%s" for using password-based authentication.', get_debug_type($user), PasswordAuthenticatedUserInterface::class)); } /** @var PasswordCredentials $badge */ @@ -74,20 +63,8 @@ public function checkPassport(CheckPassportEvent $event): void throw new BadCredentialsException('The presented password is invalid.'); } - $salt = method_exists($user, 'getSalt') ? $user->getSalt() : ''; - if ($salt && !$user instanceof LegacyPasswordAuthenticatedUserInterface) { - trigger_deprecation('symfony/security-http', '5.3', 'Returning a string from "getSalt()" without implementing the "%s" interface is deprecated, the "%s" class should implement it.', LegacyPasswordAuthenticatedUserInterface::class, get_debug_type($user)); - } - - // @deprecated since Symfony 5.3 - if ($this->hasherFactory instanceof EncoderFactoryInterface) { - if (!$this->hasherFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $salt)) { - throw new BadCredentialsException('The presented password is invalid.'); - } - } else { - if (!$this->hasherFactory->getPasswordHasher($user)->verify($user->getPassword(), $presentedPassword, $salt)) { - throw new BadCredentialsException('The presented password is invalid.'); - } + if (!$this->hasherFactory->getPasswordHasher($user)->verify($user->getPassword(), $presentedPassword, $user instanceof LegacyPasswordAuthenticatedUserInterface ? $user->getSalt() : null)) { + throw new BadCredentialsException('The presented password is invalid.'); } $badge->markResolved(); diff --git a/EventListener/CheckRememberMeConditionsListener.php b/EventListener/CheckRememberMeConditionsListener.php index 1eba75d9..775c0a1a 100644 --- a/EventListener/CheckRememberMeConditionsListener.php +++ b/EventListener/CheckRememberMeConditionsListener.php @@ -35,13 +35,13 @@ */ class CheckRememberMeConditionsListener implements EventSubscriberInterface { - private $options; - private $logger; + private array $options; - public function __construct(array $options = [], ?LoggerInterface $logger = null) - { + public function __construct( + array $options = [], + private ?LoggerInterface $logger = null, + ) { $this->options = $options + ['always_remember_me' => false, 'remember_me_parameter' => '_remember_me']; - $this->logger = $logger; } public function onSuccessfulLogin(LoginSuccessEvent $event): void @@ -54,11 +54,9 @@ public function onSuccessfulLogin(LoginSuccessEvent $event): void /** @var RememberMeBadge $badge */ $badge = $passport->getBadge(RememberMeBadge::class); if (!$this->options['always_remember_me']) { - $parameter = ParameterBagUtils::getRequestParameterValue($event->getRequest(), $this->options['remember_me_parameter']); - if (!('true' === $parameter || 'on' === $parameter || '1' === $parameter || 'yes' === $parameter || true === $parameter)) { - if (null !== $this->logger) { - $this->logger->debug('Remember me disabled; request does not contain remember me parameter ("{parameter}").', ['parameter' => $this->options['remember_me_parameter']]); - } + $parameter = ParameterBagUtils::getRequestParameterValue($event->getRequest(), $this->options['remember_me_parameter'], $badge->parameters); + if (!filter_var($parameter, \FILTER_VALIDATE_BOOL)) { + $this->logger?->debug('Remember me disabled; request does not contain remember me parameter ("{parameter}").', ['parameter' => $this->options['remember_me_parameter']]); return; } diff --git a/EventListener/ClearSiteDataLogoutListener.php b/EventListener/ClearSiteDataLogoutListener.php new file mode 100644 index 00000000..77ca07a6 --- /dev/null +++ b/EventListener/ClearSiteDataLogoutListener.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; + +/** + * Handler for Clear-Site-Data header during logout. + * + * @author Max Beckers + * + * @final + */ +class ClearSiteDataLogoutListener implements EventSubscriberInterface +{ + private const HEADER_NAME = 'Clear-Site-Data'; + + /** + * @param string[] $cookieValue The value for the Clear-Site-Data header. + * Can be '*' or a subset of 'cache', 'cookies', 'storage', 'executionContexts'. + */ + public function __construct(private readonly array $cookieValue) + { + } + + public function onLogout(LogoutEvent $event): void + { + if (!$event->getResponse()?->headers->has(static::HEADER_NAME)) { + $event->getResponse()->headers->set(static::HEADER_NAME, implode(', ', array_map(fn ($v) => '"'.$v.'"', $this->cookieValue))); + } + } + + public static function getSubscribedEvents(): array + { + return [ + LogoutEvent::class => 'onLogout', + ]; + } +} diff --git a/EventListener/CookieClearingLogoutListener.php b/EventListener/CookieClearingLogoutListener.php index d178b926..040b7d9a 100644 --- a/EventListener/CookieClearingLogoutListener.php +++ b/EventListener/CookieClearingLogoutListener.php @@ -23,14 +23,12 @@ */ class CookieClearingLogoutListener implements EventSubscriberInterface { - private $cookies; - /** * @param array $cookies An array of cookies (keys are names, values contain path and domain) to unset */ - public function __construct(array $cookies) - { - $this->cookies = $cookies; + public function __construct( + private array $cookies, + ) { } public function onLogout(LogoutEvent $event): void @@ -40,7 +38,7 @@ public function onLogout(LogoutEvent $event): void } foreach ($this->cookies as $cookieName => $cookieData) { - $response->headers->clearCookie($cookieName, $cookieData['path'], $cookieData['domain'], $cookieData['secure'] ?? false, true, $cookieData['samesite'] ?? null); + $response->headers->clearCookie($cookieName, $cookieData['path'], $cookieData['domain'], $cookieData['secure'] ?? false, true, $cookieData['samesite'] ?? null, $cookieData['partitioned'] ?? false); } } diff --git a/EventListener/CsrfProtectionListener.php b/EventListener/CsrfProtectionListener.php index 91f46f36..4bbd8adc 100644 --- a/EventListener/CsrfProtectionListener.php +++ b/EventListener/CsrfProtectionListener.php @@ -25,11 +25,9 @@ */ class CsrfProtectionListener implements EventSubscriberInterface { - private $csrfTokenManager; - - public function __construct(CsrfTokenManagerInterface $csrfTokenManager) - { - $this->csrfTokenManager = $csrfTokenManager; + public function __construct( + private CsrfTokenManagerInterface $csrfTokenManager, + ) { } public function checkPassport(CheckPassportEvent $event): void diff --git a/EventListener/CsrfTokenClearingLogoutListener.php b/EventListener/CsrfTokenClearingLogoutListener.php index 984041ee..06e791bb 100644 --- a/EventListener/CsrfTokenClearingLogoutListener.php +++ b/EventListener/CsrfTokenClearingLogoutListener.php @@ -13,6 +13,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Csrf\TokenStorage\ClearableTokenStorageInterface; +use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage; use Symfony\Component\Security\Http\Event\LogoutEvent; /** @@ -22,15 +23,17 @@ */ class CsrfTokenClearingLogoutListener implements EventSubscriberInterface { - private $csrfTokenStorage; - - public function __construct(ClearableTokenStorageInterface $csrfTokenStorage) - { - $this->csrfTokenStorage = $csrfTokenStorage; + public function __construct( + private ClearableTokenStorageInterface $csrfTokenStorage, + ) { } public function onLogout(LogoutEvent $event): void { + if ($this->csrfTokenStorage instanceof SessionTokenStorage && !$event->getRequest()->hasPreviousSession()) { + return; + } + $this->csrfTokenStorage->clear(); } diff --git a/EventListener/DefaultLogoutListener.php b/EventListener/DefaultLogoutListener.php index 8a9e0004..e3680473 100644 --- a/EventListener/DefaultLogoutListener.php +++ b/EventListener/DefaultLogoutListener.php @@ -25,13 +25,10 @@ */ class DefaultLogoutListener implements EventSubscriberInterface { - private $httpUtils; - private $targetUrl; - - public function __construct(HttpUtils $httpUtils, string $targetUrl = '/') - { - $this->httpUtils = $httpUtils; - $this->targetUrl = $targetUrl; + public function __construct( + private HttpUtils $httpUtils, + private string $targetUrl = '/', + ) { } public function onLogout(LogoutEvent $event): void diff --git a/EventListener/IsCsrfTokenValidAttributeListener.php b/EventListener/IsCsrfTokenValidAttributeListener.php new file mode 100644 index 00000000..336b794f --- /dev/null +++ b/EventListener/IsCsrfTokenValidAttributeListener.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; + +/** + * Handles the IsCsrfTokenValid attribute on controllers. + */ +final class IsCsrfTokenValidAttributeListener implements EventSubscriberInterface +{ + public function __construct( + private readonly CsrfTokenManagerInterface $csrfTokenManager, + private ?ExpressionLanguage $expressionLanguage = null, + ) { + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var IsCsrfTokenValid[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[IsCsrfTokenValid::class] ?? null)) { + return; + } + + $request = $event->getRequest(); + $arguments = $event->getNamedArguments(); + + foreach ($attributes as $attribute) { + $id = $this->getTokenId($attribute->id, $request, $arguments); + $methods = array_map('strtoupper', (array) $attribute->methods); + + if ($methods && !\in_array($request->getMethod(), $methods, true)) { + continue; + } + + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $request->getPayload()->getString($attribute->tokenKey)))) { + throw new InvalidCsrfTokenException('Invalid CSRF token.'); + } + } + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 25]]; + } + + private function getTokenId(string|Expression $id, Request $request, array $arguments): string + { + if (!$id instanceof Expression) { + return $id; + } + + $this->expressionLanguage ??= new ExpressionLanguage(); + + return (string) $this->expressionLanguage->evaluate($id, [ + 'request' => $request, + 'args' => $arguments, + ]); + } +} diff --git a/EventListener/IsGrantedAttributeListener.php b/EventListener/IsGrantedAttributeListener.php new file mode 100644 index 00000000..607643ce --- /dev/null +++ b/EventListener/IsGrantedAttributeListener.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Exception\RuntimeException; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +/** + * Handles the IsGranted attribute on controllers. + * + * @author Ryan Weaver + */ +class IsGrantedAttributeListener implements EventSubscriberInterface +{ + public function __construct( + private readonly AuthorizationCheckerInterface $authChecker, + private ?ExpressionLanguage $expressionLanguage = null, + ) { + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var IsGranted[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[IsGranted::class] ?? null)) { + return; + } + + $request = $event->getRequest(); + $arguments = $event->getNamedArguments(); + + foreach ($attributes as $attribute) { + $subject = null; + + if ($subjectRef = $attribute->subject) { + if (\is_array($subjectRef)) { + foreach ($subjectRef as $refKey => $ref) { + $subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $request, $arguments); + } + } else { + $subject = $this->getIsGrantedSubject($subjectRef, $request, $arguments); + } + } + $accessDecision = new AccessDecision(); + + if (!$accessDecision->isGranted = $this->authChecker->isGranted($attribute->attribute, $subject, $accessDecision)) { + $message = $attribute->message ?: $accessDecision->getMessage(); + + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $message, code: $attribute->exceptionCode ?? 0); + } + + $e = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403); + $e->setAttributes([$attribute->attribute]); + $e->setSubject($subject); + $e->setAccessDecision($accessDecision); + + throw $e; + } + } + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 20]]; + } + + private function getIsGrantedSubject(string|Expression|\Closure $subjectRef, Request $request, array $arguments): mixed + { + if ($subjectRef instanceof \Closure) { + return $subjectRef($arguments, $request); + } + + if ($subjectRef instanceof Expression) { + $this->expressionLanguage ??= new ExpressionLanguage(); + + return $this->expressionLanguage->evaluate($subjectRef, [ + 'request' => $request, + 'args' => $arguments, + ]); + } + + if (!\array_key_exists($subjectRef, $arguments)) { + throw new RuntimeException(\sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your controller method.', $subjectRef, $subjectRef)); + } + + return $arguments[$subjectRef]; + } +} diff --git a/EventListener/LoginThrottlingListener.php b/EventListener/LoginThrottlingListener.php index 2284f932..7669c743 100644 --- a/EventListener/LoginThrottlingListener.php +++ b/EventListener/LoginThrottlingListener.php @@ -12,26 +12,25 @@ namespace Symfony\Component\Security\Http\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\RateLimiter\PeekableRequestRateLimiterInterface; use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\SecurityRequestAttributes; /** * @author Wouter de Jong */ final class LoginThrottlingListener implements EventSubscriberInterface { - private $requestStack; - private $limiter; - - public function __construct(RequestStack $requestStack, RequestRateLimiterInterface $limiter) - { - $this->requestStack = $requestStack; - $this->limiter = $limiter; + public function __construct( + private RequestStack $requestStack, + private RequestRateLimiterInterface $limiter, + ) { } public function checkPassport(CheckPassportEvent $event): void @@ -42,23 +41,43 @@ public function checkPassport(CheckPassportEvent $event): void } $request = $this->requestStack->getMainRequest(); - $request->attributes->set(Security::LAST_USERNAME, $passport->getBadge(UserBadge::class)->getUserIdentifier()); + $request->attributes->set(SecurityRequestAttributes::LAST_USERNAME, $passport->getBadge(UserBadge::class)->getUserIdentifier()); - $limit = $this->limiter->consume($request); - if (!$limit->isAccepted()) { - throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60)); + if ($this->limiter instanceof PeekableRequestRateLimiterInterface) { + $limit = $this->limiter->peek($request); + // Checking isAccepted here is not enough as peek consumes 0 token, it will + // be accepted even if there are 0 tokens remaining to be consumed. We check both + // anyway for safety in case third party implementations behave unexpectedly. + if (!$limit->isAccepted() || 0 === $limit->getRemainingTokens()) { + throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60)); + } + } else { + $limit = $this->limiter->consume($request); + if (!$limit->isAccepted()) { + throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60)); + } } } public function onSuccessfulLogin(LoginSuccessEvent $event): void { - $this->limiter->reset($event->getRequest()); + if (!$this->limiter instanceof PeekableRequestRateLimiterInterface) { + $this->limiter->reset($event->getRequest()); + } + } + + public function onFailedLogin(LoginFailureEvent $event): void + { + if ($this->limiter instanceof PeekableRequestRateLimiterInterface) { + $this->limiter->consume($event->getRequest()); + } } public static function getSubscribedEvents(): array { return [ CheckPassportEvent::class => ['checkPassport', 2080], + LoginFailureEvent::class => 'onFailedLogin', LoginSuccessEvent::class => 'onSuccessfulLogin', ]; } diff --git a/EventListener/PasswordMigratingListener.php b/EventListener/PasswordMigratingListener.php index 2650d458..152e5fa1 100644 --- a/EventListener/PasswordMigratingListener.php +++ b/EventListener/PasswordMigratingListener.php @@ -13,12 +13,11 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; -use Symfony\Component\PasswordHasher\PasswordHasherInterface; -use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\User\LegacyPasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; /** @@ -28,24 +27,15 @@ */ class PasswordMigratingListener implements EventSubscriberInterface { - private $hasherFactory; - - /** - * @param PasswordHasherFactoryInterface $hasherFactory - */ - public function __construct($hasherFactory) - { - if ($hasherFactory instanceof EncoderFactoryInterface) { - trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); - } - - $this->hasherFactory = $hasherFactory; + public function __construct( + private PasswordHasherFactoryInterface $hasherFactory, + ) { } public function onLoginSuccess(LoginSuccessEvent $event): void { $passport = $event->getPassport(); - if (!$passport instanceof UserPassportInterface || !$passport->hasBadge(PasswordUpgradeBadge::class)) { + if (!$passport->hasBadge(PasswordUpgradeBadge::class)) { return; } @@ -58,11 +48,11 @@ public function onLoginSuccess(LoginSuccessEvent $event): void } $user = $passport->getUser(); - if (null === $user->getPassword()) { + if (!$user instanceof PasswordAuthenticatedUserInterface || null === $user->getPassword()) { return; } - $passwordHasher = $this->hasherFactory instanceof EncoderFactoryInterface ? $this->hasherFactory->getEncoder($user) : $this->hasherFactory->getPasswordHasher($user); + $passwordHasher = $this->hasherFactory->getPasswordHasher($user); if (!$passwordHasher->needsRehash($user->getPassword())) { return; } @@ -86,7 +76,12 @@ public function onLoginSuccess(LoginSuccessEvent $event): void } } - $passwordUpgrader->upgradePassword($user, $passwordHasher instanceof PasswordHasherInterface ? $passwordHasher->hash($plaintextPassword, $user->getSalt()) : $passwordHasher->encodePassword($plaintextPassword, $user->getSalt())); + $salt = null; + if ($user instanceof LegacyPasswordAuthenticatedUserInterface) { + $salt = $user->getSalt(); + } + + $passwordUpgrader->upgradePassword($user, $passwordHasher->hash($plaintextPassword, $salt)); } public static function getSubscribedEvents(): array diff --git a/EventListener/RememberMeListener.php b/EventListener/RememberMeListener.php index 06ac19f6..a6b5c166 100644 --- a/EventListener/RememberMeListener.php +++ b/EventListener/RememberMeListener.php @@ -34,22 +34,17 @@ */ class RememberMeListener implements EventSubscriberInterface { - private $rememberMeHandler; - private $logger; - - public function __construct(RememberMeHandlerInterface $rememberMeHandler, ?LoggerInterface $logger = null) - { - $this->rememberMeHandler = $rememberMeHandler; - $this->logger = $logger; + public function __construct( + private RememberMeHandlerInterface $rememberMeHandler, + private ?LoggerInterface $logger = null, + ) { } public function onSuccessfulLogin(LoginSuccessEvent $event): void { $passport = $event->getPassport(); if (!$passport->hasBadge(RememberMeBadge::class)) { - if (null !== $this->logger) { - $this->logger->debug('Remember me skipped: your authenticator does not support it.', ['authenticator' => \get_class($event->getAuthenticator())]); - } + $this->logger?->debug('Remember me skipped: your authenticator does not support it.', ['authenticator' => $event->getAuthenticator()::class]); return; } @@ -60,16 +55,12 @@ public function onSuccessfulLogin(LoginSuccessEvent $event): void /** @var RememberMeBadge $badge */ $badge = $passport->getBadge(RememberMeBadge::class); if (!$badge->isEnabled()) { - if (null !== $this->logger) { - $this->logger->debug('Remember me skipped: the RememberMeBadge is not enabled.'); - } + $this->logger?->debug('Remember me skipped: the RememberMeBadge is not enabled.'); return; } - if (null !== $this->logger) { - $this->logger->debug('Remember-me was requested; setting cookie.'); - } + $this->logger?->debug('Remember-me was requested; setting cookie.'); $this->rememberMeHandler->createRememberMeCookie($event->getUser()); } diff --git a/EventListener/RememberMeLogoutListener.php b/EventListener/RememberMeLogoutListener.php deleted file mode 100644 index b97558f3..00000000 --- a/EventListener/RememberMeLogoutListener.php +++ /dev/null @@ -1,64 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\EventListener; - -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\Security\Core\Exception\LogicException; -use Symfony\Component\Security\Http\Event\LogoutEvent; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated.', RememberMeLogoutListener::class); - -/** - * @author Wouter de Jong - * - * @final - * - * @deprecated since Symfony 5.4 - */ -class RememberMeLogoutListener implements EventSubscriberInterface -{ - private $rememberMeServices; - - public function __construct(RememberMeServicesInterface $rememberMeServices) - { - if (!method_exists($rememberMeServices, 'logout')) { - trigger_deprecation('symfony/security-core', '5.1', '"%s" should implement the "logout(Request $request, Response $response, TokenInterface $token)" method, this method will be added to the "%s" in version 6.0.', \get_class($rememberMeServices), RememberMeServicesInterface::class); - } - - $this->rememberMeServices = $rememberMeServices; - } - - public function onLogout(LogoutEvent $event): void - { - if (!method_exists($this->rememberMeServices, 'logout')) { - return; - } - - if (!$event->getToken()) { - return; - } - - if (null === $event->getResponse()) { - throw new LogicException(sprintf('No response was set for this logout action. Make sure the DefaultLogoutListener or another listener has set the response before "%s" is called.', __CLASS__)); - } - - $this->rememberMeServices->logout($event->getRequest(), $event->getResponse(), $event->getToken()); - } - - public static function getSubscribedEvents(): array - { - return [ - LogoutEvent::class => 'onLogout', - ]; - } -} diff --git a/EventListener/SessionStrategyListener.php b/EventListener/SessionStrategyListener.php index c6fcba88..8267e036 100644 --- a/EventListener/SessionStrategyListener.php +++ b/EventListener/SessionStrategyListener.php @@ -27,11 +27,9 @@ */ class SessionStrategyListener implements EventSubscriberInterface { - private $sessionAuthenticationStrategy; - - public function __construct(SessionAuthenticationStrategyInterface $sessionAuthenticationStrategy) - { - $this->sessionAuthenticationStrategy = $sessionAuthenticationStrategy; + public function __construct( + private SessionAuthenticationStrategyInterface $sessionAuthenticationStrategy, + ) { } public function onSuccessfulLogin(LoginSuccessEvent $event): void @@ -39,16 +37,15 @@ public function onSuccessfulLogin(LoginSuccessEvent $event): void $request = $event->getRequest(); $token = $event->getAuthenticatedToken(); - if (!$request->hasSession() || !$request->hasPreviousSession()) { + if (!$request->hasPreviousSession()) { return; } if ($previousToken = $event->getPreviousToken()) { - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - $user = method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(); - $previousUser = method_exists($previousToken, 'getUserIdentifier') ? $previousToken->getUserIdentifier() : $previousToken->getUsername(); + $user = $token->getUserIdentifier(); + $previousUser = $previousToken->getUserIdentifier(); - if ('' !== ($user ?? '') && $user === $previousUser && \get_class($token) === \get_class($previousToken)) { + if ('' !== $user && $user === $previousUser && $token::class === $previousToken::class) { return; } } diff --git a/EventListener/UserCheckerListener.php b/EventListener/UserCheckerListener.php index 55be8b7a..9de200dd 100644 --- a/EventListener/UserCheckerListener.php +++ b/EventListener/UserCheckerListener.php @@ -16,7 +16,6 @@ use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PreAuthenticatedUserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\CheckPassportEvent; /** @@ -26,17 +25,15 @@ */ class UserCheckerListener implements EventSubscriberInterface { - private $userChecker; - - public function __construct(UserCheckerInterface $userChecker) - { - $this->userChecker = $userChecker; + public function __construct( + private UserCheckerInterface $userChecker, + ) { } public function preCheckCredentials(CheckPassportEvent $event): void { $passport = $event->getPassport(); - if (!$passport instanceof UserPassportInterface || $passport->hasBadge(PreAuthenticatedUserBadge::class)) { + if ($passport->hasBadge(PreAuthenticatedUserBadge::class)) { return; } @@ -50,7 +47,7 @@ public function postCheckCredentials(AuthenticationSuccessEvent $event): void return; } - $this->userChecker->checkPostAuth($user); + $this->userChecker->checkPostAuth($user, $event->getAuthenticationToken()); } public static function getSubscribedEvents(): array diff --git a/EventListener/UserProviderListener.php b/EventListener/UserProviderListener.php index a3408789..088cfd01 100644 --- a/EventListener/UserProviderListener.php +++ b/EventListener/UserProviderListener.php @@ -25,11 +25,9 @@ */ class UserProviderListener { - private $userProvider; - - public function __construct(UserProviderInterface $userProvider) - { - $this->userProvider = $userProvider; + public function __construct( + private UserProviderInterface $userProvider, + ) { } public function checkPassport(CheckPassportEvent $event): void @@ -45,13 +43,6 @@ public function checkPassport(CheckPassportEvent $event): void return; } - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - if (method_exists($this->userProvider, 'loadUserByIdentifier')) { - $badge->setUserLoader([$this->userProvider, 'loadUserByIdentifier']); - } else { - trigger_deprecation('symfony/security-http', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); - - $badge->setUserLoader([$this->userProvider, 'loadUserByUsername']); - } + $badge->setUserLoader($this->userProvider->loadUserByIdentifier(...)); } } diff --git a/Firewall.php b/Firewall.php index be758a63..da616e86 100644 --- a/Firewall.php +++ b/Firewall.php @@ -32,21 +32,21 @@ */ class Firewall implements EventSubscriberInterface { - private $map; - private $dispatcher; - /** * @var \SplObjectStorage */ - private $exceptionListeners; + private \SplObjectStorage $exceptionListeners; - public function __construct(FirewallMapInterface $map, EventDispatcherInterface $dispatcher) - { - $this->map = $map; - $this->dispatcher = $dispatcher; + public function __construct( + private FirewallMapInterface $map, + private EventDispatcherInterface $dispatcher, + ) { $this->exceptionListeners = new \SplObjectStorage(); } + /** + * @return void + */ public function onKernelRequest(RequestEvent $event) { if (!$event->isMainRequest()) { @@ -92,6 +92,9 @@ public function onKernelRequest(RequestEvent $event) $this->callListeners($event, $authenticationListeners()); } + /** + * @return void + */ public function onKernelFinishRequest(FinishRequestEvent $event) { $request = $event->getRequest(); @@ -103,7 +106,7 @@ public function onKernelFinishRequest(FinishRequestEvent $event) } /** - * {@inheritdoc} + * @return array */ public static function getSubscribedEvents() { @@ -113,10 +116,17 @@ public static function getSubscribedEvents() ]; } + /** + * @return void + */ protected function callListeners(RequestEvent $event, iterable $listeners) { foreach ($listeners as $listener) { - $listener($event); + if (!$listener instanceof FirewallListenerInterface) { + $listener($event); + } elseif (false !== $listener->supports($event->getRequest())) { + $listener->authenticate($event); + } if ($event->hasResponse()) { break; diff --git a/Firewall/AbstractAuthenticationListener.php b/Firewall/AbstractAuthenticationListener.php deleted file mode 100644 index 45df2d01..00000000 --- a/Firewall/AbstractAuthenticationListener.php +++ /dev/null @@ -1,245 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\SessionUnavailableException; -use Symfony\Component\Security\Core\Security; -use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; -use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; -use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; -use Symfony\Component\Security\Http\SecurityEvents; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', AbstractAuthenticationListener::class); - -/** - * The AbstractAuthenticationListener is the preferred base class for all - * browser-/HTTP-based authentication requests. - * - * Subclasses likely have to implement the following: - * - an TokenInterface to hold authentication related data - * - an AuthenticationProvider to perform the actual authentication of the - * token, retrieve the UserInterface implementation from a database, and - * perform the specific account checks using the UserChecker - * - * By default, this listener only is active for a specific path, e.g. - * /login_check. If you want to change this behavior, you can overwrite the - * requiresAuthentication() method. - * - * @author Fabien Potencier - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -abstract class AbstractAuthenticationListener extends AbstractListener -{ - protected $options; - protected $logger; - protected $authenticationManager; - protected $providerKey; - protected $httpUtils; - - private $tokenStorage; - private $sessionStrategy; - private $dispatcher; - private $successHandler; - private $failureHandler; - private $rememberMeServices; - - /** - * @throws \InvalidArgumentException - */ - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, string $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = [], ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null) - { - if (empty($providerKey)) { - throw new \InvalidArgumentException('$providerKey must not be empty.'); - } - - $this->tokenStorage = $tokenStorage; - $this->authenticationManager = $authenticationManager; - $this->sessionStrategy = $sessionStrategy; - $this->providerKey = $providerKey; - $this->successHandler = $successHandler; - $this->failureHandler = $failureHandler; - $this->options = array_merge([ - 'check_path' => '/login_check', - 'login_path' => '/login', - 'always_use_default_target_path' => false, - 'default_target_path' => '/', - 'target_path_parameter' => '_target_path', - 'use_referer' => false, - 'failure_path' => null, - 'failure_forward' => false, - 'require_previous_session' => true, - ], $options); - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->httpUtils = $httpUtils; - } - - /** - * Sets the RememberMeServices implementation to use. - */ - public function setRememberMeServices(RememberMeServicesInterface $rememberMeServices) - { - $this->rememberMeServices = $rememberMeServices; - } - - /** - * {@inheritdoc} - */ - public function supports(Request $request): ?bool - { - return $this->requiresAuthentication($request); - } - - /** - * Handles form based authentication. - * - * @throws \RuntimeException - * @throws SessionUnavailableException - */ - public function authenticate(RequestEvent $event) - { - $request = $event->getRequest(); - - if (!$request->hasSession()) { - throw new \RuntimeException('This authentication method requires a session.'); - } - - try { - if ($this->options['require_previous_session'] && !$request->hasPreviousSession()) { - throw new SessionUnavailableException('Your session has timed out, or you have disabled cookies.'); - } - - $previousToken = $this->tokenStorage->getToken(); - - if (null === $returnValue = $this->attemptAuthentication($request)) { - return; - } - - if ($returnValue instanceof TokenInterface) { - $this->migrateSession($request, $returnValue, $previousToken); - - $response = $this->onSuccess($request, $returnValue); - } elseif ($returnValue instanceof Response) { - $response = $returnValue; - } else { - throw new \RuntimeException('attemptAuthentication() must either return a Response, an implementation of TokenInterface, or null.'); - } - } catch (AuthenticationException $e) { - $response = $this->onFailure($request, $e); - } - - $event->setResponse($response); - } - - /** - * Whether this request requires authentication. - * - * The default implementation only processes requests to a specific path, - * but a subclass could change this to only authenticate requests where a - * certain parameters is present. - * - * @return bool - */ - protected function requiresAuthentication(Request $request) - { - return $this->httpUtils->checkRequestPath($request, $this->options['check_path']); - } - - /** - * Performs authentication. - * - * @return TokenInterface|Response|null The authenticated token, null if full authentication is not possible, or a Response - * - * @throws AuthenticationException if the authentication fails - */ - abstract protected function attemptAuthentication(Request $request); - - private function onFailure(Request $request, AuthenticationException $failed): Response - { - if (null !== $this->logger) { - $this->logger->info('Authentication request failed.', ['exception' => $failed]); - } - - $token = $this->tokenStorage->getToken(); - if ($token instanceof UsernamePasswordToken && $this->providerKey === $token->getFirewallName()) { - $this->tokenStorage->setToken(null); - } - - $response = $this->failureHandler->onAuthenticationFailure($request, $failed); - - if (!$response instanceof Response) { - throw new \RuntimeException('Authentication Failure Handler did not return a Response.'); - } - - return $response; - } - - private function onSuccess(Request $request, TokenInterface $token): Response - { - if (null !== $this->logger) { - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - $this->logger->info('User has been authenticated successfully.', ['username' => method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername()]); - } - - $this->tokenStorage->setToken($token); - - $session = $request->getSession(); - $session->remove(Security::AUTHENTICATION_ERROR); - $session->remove(Security::LAST_USERNAME); - - if (null !== $this->dispatcher) { - $loginEvent = new InteractiveLoginEvent($request, $token); - $this->dispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN); - } - - $response = $this->successHandler->onAuthenticationSuccess($request, $token); - - if (!$response instanceof Response) { - throw new \RuntimeException('Authentication Success Handler did not return a Response.'); - } - - if (null !== $this->rememberMeServices) { - $this->rememberMeServices->loginSuccess($request, $response, $token); - } - - return $response; - } - - private function migrateSession(Request $request, TokenInterface $token, ?TokenInterface $previousToken) - { - if ($previousToken) { - $user = method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(); - $previousUser = method_exists($previousToken, 'getUserIdentifier') ? $previousToken->getUserIdentifier() : $previousToken->getUsername(); - - if ('' !== ($user ?? '') && $user === $previousUser) { - return; - } - } - - $this->sessionStrategy->onAuthentication($request, $token); - } -} diff --git a/Firewall/AbstractListener.php b/Firewall/AbstractListener.php index cbc8f938..b5349e5e 100644 --- a/Firewall/AbstractListener.php +++ b/Firewall/AbstractListener.php @@ -20,7 +20,7 @@ */ abstract class AbstractListener implements FirewallListenerInterface { - final public function __invoke(RequestEvent $event) + final public function __invoke(RequestEvent $event): void { if (false !== $this->supports($event->getRequest())) { $this->authenticate($event); diff --git a/Firewall/AbstractPreAuthenticatedListener.php b/Firewall/AbstractPreAuthenticatedListener.php deleted file mode 100644 index 7a8b2129..00000000 --- a/Firewall/AbstractPreAuthenticatedListener.php +++ /dev/null @@ -1,170 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; -use Symfony\Component\Security\Http\SecurityEvents; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', AbstractPreAuthenticatedListener::class); - -/** - * AbstractPreAuthenticatedListener is the base class for all listener that - * authenticates users based on a pre-authenticated request (like a certificate - * for instance). - * - * @author Fabien Potencier - * - * @internal - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -abstract class AbstractPreAuthenticatedListener extends AbstractListener -{ - protected $logger; - private $tokenStorage; - private $authenticationManager; - private $providerKey; - private $dispatcher; - private $sessionStrategy; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, string $providerKey, ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null) - { - $this->tokenStorage = $tokenStorage; - $this->authenticationManager = $authenticationManager; - $this->providerKey = $providerKey; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - } - - /** - * {@inheritdoc} - */ - public function supports(Request $request): ?bool - { - try { - $request->attributes->set('_pre_authenticated_data', $this->getPreAuthenticatedData($request)); - } catch (BadCredentialsException $e) { - $this->clearToken($e); - - return false; - } - - return true; - } - - /** - * Handles pre-authentication. - */ - public function authenticate(RequestEvent $event) - { - $request = $event->getRequest(); - - [$user, $credentials] = $request->attributes->get('_pre_authenticated_data'); - $request->attributes->remove('_pre_authenticated_data'); - - if (null !== $this->logger) { - $this->logger->debug('Checking current security token.', ['token' => (string) $this->tokenStorage->getToken()]); - } - - if (null !== $token = $this->tokenStorage->getToken()) { - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - if ($token instanceof PreAuthenticatedToken && $this->providerKey == $token->getFirewallName() && $token->isAuthenticated() && (method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername()) === $user) { - return; - } - } - - if (null !== $this->logger) { - $this->logger->debug('Trying to pre-authenticate user.', ['username' => (string) $user]); - } - - try { - $previousToken = $token; - $token = $this->authenticationManager->authenticate(new PreAuthenticatedToken($user, $credentials, $this->providerKey)); - - if (null !== $this->logger) { - $this->logger->info('Pre-authentication successful.', ['token' => (string) $token]); - } - - $this->migrateSession($request, $token, $previousToken); - - $this->tokenStorage->setToken($token); - - if (null !== $this->dispatcher) { - $loginEvent = new InteractiveLoginEvent($request, $token); - $this->dispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN); - } - } catch (AuthenticationException $e) { - $this->clearToken($e); - } - } - - /** - * Call this method if your authentication token is stored to a session. - * - * @final - */ - public function setSessionAuthenticationStrategy(SessionAuthenticationStrategyInterface $sessionStrategy) - { - $this->sessionStrategy = $sessionStrategy; - } - - /** - * Clears a PreAuthenticatedToken for this provider (if present). - */ - private function clearToken(AuthenticationException $exception) - { - $token = $this->tokenStorage->getToken(); - if ($token instanceof PreAuthenticatedToken && $this->providerKey === $token->getFirewallName()) { - $this->tokenStorage->setToken(null); - - if (null !== $this->logger) { - $this->logger->info('Cleared security token due to an exception.', ['exception' => $exception]); - } - } - } - - /** - * Gets the user and credentials from the Request. - * - * @return array An array composed of the user and the credentials - */ - abstract protected function getPreAuthenticatedData(Request $request); - - private function migrateSession(Request $request, TokenInterface $token, ?TokenInterface $previousToken) - { - if (!$this->sessionStrategy || !$request->hasSession() || !$request->hasPreviousSession()) { - return; - } - - if ($previousToken) { - $user = method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(); - $previousUser = method_exists($previousToken, 'getUserIdentifier') ? $previousToken->getUserIdentifier() : $previousToken->getUsername(); - - if ('' !== ($user ?? '') && $user === $previousUser) { - return; - } - } - - $this->sessionStrategy->onAuthentication($request, $token); - } -} diff --git a/Firewall/AccessListener.php b/Firewall/AccessListener.php index 8bea8564..8bfcb674 100644 --- a/Firewall/AccessListener.php +++ b/Firewall/AccessListener.php @@ -13,15 +13,13 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\Exception\AccessDeniedException; -use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Security\Http\AccessMapInterface; -use Symfony\Component\Security\Http\Authentication\NoopAuthenticationManager; use Symfony\Component\Security\Http\Event\LazyResponseEvent; /** @@ -33,43 +31,23 @@ */ class AccessListener extends AbstractListener { - private $tokenStorage; - private $accessDecisionManager; - private $map; - private $authManager; - private $exceptionOnNoToken; - - public function __construct(TokenStorageInterface $tokenStorage, AccessDecisionManagerInterface $accessDecisionManager, AccessMapInterface $map, /* bool */ $exceptionOnNoToken = true) - { - if ($exceptionOnNoToken instanceof AuthenticationManagerInterface) { - trigger_deprecation('symfony/security-http', '5.4', 'The $authManager argument of "%s" is deprecated.', __METHOD__); - $authManager = $exceptionOnNoToken; - $exceptionOnNoToken = \func_num_args() > 4 ? func_get_arg(4) : true; - } - + public function __construct( + private TokenStorageInterface $tokenStorage, + private AccessDecisionManagerInterface $accessDecisionManager, + private AccessMapInterface $map, + bool $exceptionOnNoToken = false, + ) { if (false !== $exceptionOnNoToken) { - trigger_deprecation('symfony/security-http', '5.4', 'Not setting the $exceptionOnNoToken argument of "%s" to "false" is deprecated.', __METHOD__); + throw new \LogicException(\sprintf('Argument $exceptionOnNoToken of "%s()" must be set to "false".', __METHOD__)); } - - $this->tokenStorage = $tokenStorage; - $this->accessDecisionManager = $accessDecisionManager; - $this->map = $map; - $this->authManager = $authManager ?? (class_exists(AuthenticationManagerInterface::class) ? new NoopAuthenticationManager() : null); - $this->exceptionOnNoToken = $exceptionOnNoToken; } - /** - * {@inheritdoc} - */ public function supports(Request $request): ?bool { [$attributes] = $this->map->getPatterns($request); $request->attributes->set('_access_control_attributes', $attributes); - if ($attributes && ( - (\defined(AuthenticatedVoter::class.'::IS_AUTHENTICATED_ANONYMOUSLY') ? [AuthenticatedVoter::IS_AUTHENTICATED_ANONYMOUSLY] !== $attributes : true) - && [AuthenticatedVoter::PUBLIC_ACCESS] !== $attributes - )) { + if ($attributes && [AuthenticatedVoter::PUBLIC_ACCESS] !== $attributes) { return true; } @@ -80,62 +58,33 @@ public function supports(Request $request): ?bool * Handles access authorization. * * @throws AccessDeniedException - * @throws AuthenticationCredentialsNotFoundException when the token storage has no authentication token and $exceptionOnNoToken is set to true */ - public function authenticate(RequestEvent $event) + public function authenticate(RequestEvent $event): void { - if (!$event instanceof LazyResponseEvent && null === ($token = $this->tokenStorage->getToken()) && $this->exceptionOnNoToken) { - throw new AuthenticationCredentialsNotFoundException('A Token was not found in the TokenStorage.'); - } - $request = $event->getRequest(); $attributes = $request->attributes->get('_access_control_attributes'); $request->attributes->remove('_access_control_attributes'); - if (!$attributes || (( - (\defined(AuthenticatedVoter::class.'::IS_AUTHENTICATED_ANONYMOUSLY') ? [AuthenticatedVoter::IS_AUTHENTICATED_ANONYMOUSLY] === $attributes : false) - || [AuthenticatedVoter::PUBLIC_ACCESS] === $attributes - ) && $event instanceof LazyResponseEvent)) { + if (!$attributes || ( + [AuthenticatedVoter::PUBLIC_ACCESS] === $attributes && $event instanceof LazyResponseEvent + )) { return; } - if ($event instanceof LazyResponseEvent) { - $token = $this->tokenStorage->getToken(); - } - - if (null === $token) { - if ($this->exceptionOnNoToken) { - throw new AuthenticationCredentialsNotFoundException('A Token was not found in the TokenStorage.'); - } - - $token = new NullToken(); - } - - // @deprecated since Symfony 5.4 - if (method_exists($token, 'isAuthenticated') && !$token->isAuthenticated(false)) { - trigger_deprecation('symfony/core', '5.4', 'Returning false from "%s::isAuthenticated()" is deprecated, return null from "getUser()" instead.', get_debug_type($token)); + $token = $this->tokenStorage->getToken() ?? new NullToken(); + $accessDecision = new AccessDecision(); - if ($this->authManager) { - $token = $this->authManager->authenticate($token); - $this->tokenStorage->setToken($token); - } - } + if (!$accessDecision->isGranted = $this->accessDecisionManager->decide($token, $attributes, $request, $accessDecision, true)) { + $e = new AccessDeniedException($accessDecision->getMessage()); + $e->setAttributes($attributes); + $e->setSubject($request); + $e->setAccessDecision($accessDecision); - if (!$this->accessDecisionManager->decide($token, $attributes, $request, true)) { - throw $this->createAccessDeniedException($request, $attributes); + throw $e; } } - private function createAccessDeniedException(Request $request, array $attributes) - { - $exception = new AccessDeniedException(); - $exception->setAttributes($attributes); - $exception->setSubject($request); - - return $exception; - } - public static function getPriority(): int { return -255; diff --git a/Firewall/AnonymousAuthenticationListener.php b/Firewall/AnonymousAuthenticationListener.php deleted file mode 100644 index 235edaa3..00000000 --- a/Firewall/AnonymousAuthenticationListener.php +++ /dev/null @@ -1,84 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', AnonymousAuthenticationListener::class); - -// Help opcache.preload discover always-needed symbols -class_exists(AnonymousToken::class); - -/** - * AnonymousAuthenticationListener automatically adds a Token if none is - * already present. - * - * @author Fabien Potencier - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class AnonymousAuthenticationListener extends AbstractListener -{ - private $tokenStorage; - private $secret; - private $authenticationManager; - private $logger; - - public function __construct(TokenStorageInterface $tokenStorage, string $secret, ?LoggerInterface $logger = null, ?AuthenticationManagerInterface $authenticationManager = null) - { - $this->tokenStorage = $tokenStorage; - $this->secret = $secret; - $this->authenticationManager = $authenticationManager; - $this->logger = $logger; - } - - /** - * {@inheritdoc} - */ - public function supports(Request $request): ?bool - { - return null; // always run authenticate() lazily with lazy firewalls - } - - /** - * Handles anonymous authentication. - */ - public function authenticate(RequestEvent $event) - { - if (null !== $this->tokenStorage->getToken()) { - return; - } - - try { - $token = new AnonymousToken($this->secret, 'anon.', []); - if (null !== $this->authenticationManager) { - $token = $this->authenticationManager->authenticate($token); - } - - $this->tokenStorage->setToken($token); - - if (null !== $this->logger) { - $this->logger->info('Populated the TokenStorage with an anonymous Token.'); - } - } catch (AuthenticationException $failed) { - if (null !== $this->logger) { - $this->logger->info('Anonymous authentication failed.', ['exception' => $failed]); - } - } - } -} diff --git a/Firewall/AuthenticatorManagerListener.php b/Firewall/AuthenticatorManagerListener.php index 408f80c9..79d0c8d9 100644 --- a/Firewall/AuthenticatorManagerListener.php +++ b/Firewall/AuthenticatorManagerListener.php @@ -22,11 +22,9 @@ */ class AuthenticatorManagerListener extends AbstractListener { - private $authenticatorManager; - - public function __construct(AuthenticatorManagerInterface $authenticationManager) - { - $this->authenticatorManager = $authenticationManager; + public function __construct( + private AuthenticatorManagerInterface $authenticatorManager, + ) { } public function supports(Request $request): ?bool diff --git a/Firewall/BasicAuthenticationListener.php b/Firewall/BasicAuthenticationListener.php deleted file mode 100644 index 5db4d3ff..00000000 --- a/Firewall/BasicAuthenticationListener.php +++ /dev/null @@ -1,142 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', AnonymousAuthenticationListener::class); - -/** - * BasicAuthenticationListener implements Basic HTTP authentication. - * - * @author Fabien Potencier - * - * @final - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class BasicAuthenticationListener extends AbstractListener -{ - private $tokenStorage; - private $authenticationManager; - private $providerKey; - private $authenticationEntryPoint; - private $logger; - private $ignoreFailure; - private $sessionStrategy; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, string $providerKey, AuthenticationEntryPointInterface $authenticationEntryPoint, ?LoggerInterface $logger = null) - { - if (empty($providerKey)) { - throw new \InvalidArgumentException('$providerKey must not be empty.'); - } - - $this->tokenStorage = $tokenStorage; - $this->authenticationManager = $authenticationManager; - $this->providerKey = $providerKey; - $this->authenticationEntryPoint = $authenticationEntryPoint; - $this->logger = $logger; - $this->ignoreFailure = false; - } - - /** - * {@inheritdoc} - */ - public function supports(Request $request): ?bool - { - return null !== $request->headers->get('PHP_AUTH_USER'); - } - - /** - * Handles basic authentication. - */ - public function authenticate(RequestEvent $event) - { - $request = $event->getRequest(); - - if (null === $username = $request->headers->get('PHP_AUTH_USER')) { - return; - } - - if (null !== $token = $this->tokenStorage->getToken()) { - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - if ($token instanceof UsernamePasswordToken && $token->isAuthenticated(false) && (method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername()) === $username) { - return; - } - } - - if (null !== $this->logger) { - $this->logger->info('Basic authentication Authorization header found for user.', ['username' => $username]); - } - - try { - $previousToken = $token; - $token = $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $request->headers->get('PHP_AUTH_PW'), $this->providerKey)); - - $this->migrateSession($request, $token, $previousToken); - - $this->tokenStorage->setToken($token); - } catch (AuthenticationException $e) { - $token = $this->tokenStorage->getToken(); - if ($token instanceof UsernamePasswordToken && $this->providerKey === $token->getFirewallName()) { - $this->tokenStorage->setToken(null); - } - - if (null !== $this->logger) { - $this->logger->info('Basic authentication failed for user.', ['username' => $username, 'exception' => $e]); - } - - if ($this->ignoreFailure) { - return; - } - - $event->setResponse($this->authenticationEntryPoint->start($request, $e)); - } - } - - /** - * Call this method if your authentication token is stored to a session. - * - * @final - */ - public function setSessionAuthenticationStrategy(SessionAuthenticationStrategyInterface $sessionStrategy) - { - $this->sessionStrategy = $sessionStrategy; - } - - private function migrateSession(Request $request, TokenInterface $token, ?TokenInterface $previousToken) - { - if (!$this->sessionStrategy || !$request->hasSession() || !$request->hasPreviousSession()) { - return; - } - - if ($previousToken) { - $user = method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(); - $previousUser = method_exists($previousToken, 'getUserIdentifier') ? $previousToken->getUserIdentifier() : $previousToken->getUsername(); - - if ('' !== ($user ?? '') && $user === $previousUser) { - return; - } - } - - $this->sessionStrategy->onAuthentication($request, $token); - } -} diff --git a/Firewall/ChannelListener.php b/Firewall/ChannelListener.php index 5ee144b2..7631a299 100644 --- a/Firewall/ChannelListener.php +++ b/Firewall/ChannelListener.php @@ -16,7 +16,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Http\AccessMapInterface; -use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; /** * ChannelListener switches the HTTP protocol based on the access control @@ -28,32 +27,12 @@ */ class ChannelListener extends AbstractListener { - private $map; - private $authenticationEntryPoint = null; - private $logger; - private $httpPort; - private $httpsPort; - - public function __construct(AccessMapInterface $map, /* ?LoggerInterface */ $logger = null, /* int */ $httpPort = 80, /* int */ $httpsPort = 443) - { - if ($logger instanceof AuthenticationEntryPointInterface) { - trigger_deprecation('symfony/security-http', '5.4', 'The "$authenticationEntryPoint" argument of "%s()" is deprecated.', __METHOD__); - - $this->authenticationEntryPoint = $logger; - $nrOfArgs = \func_num_args(); - $logger = $nrOfArgs > 2 ? func_get_arg(2) : null; - $httpPort = $nrOfArgs > 3 ? func_get_arg(3) : 80; - $httpsPort = $nrOfArgs > 4 ? func_get_arg(4) : 443; - } - - if (null !== $logger && !$logger instanceof LoggerInterface) { - throw new \TypeError(sprintf('Argument "$logger" of "%s()" must be instance of "%s", "%s" given.', __METHOD__, LoggerInterface::class, get_debug_type($logger))); - } - - $this->map = $map; - $this->logger = $logger; - $this->httpPort = $httpPort; - $this->httpsPort = $httpsPort; + public function __construct( + private AccessMapInterface $map, + private ?LoggerInterface $logger = null, + private int $httpPort = 80, + private int $httpsPort = 443, + ) { } /** @@ -78,9 +57,7 @@ public function supports(Request $request): ?bool } if ('http' === $channel && $request->isSecure()) { - if (null !== $this->logger) { - $this->logger->info('Redirecting to HTTP.'); - } + $this->logger?->info('Redirecting to HTTP.'); return true; } @@ -88,7 +65,7 @@ public function supports(Request $request): ?bool return false; } - public function authenticate(RequestEvent $event) + public function authenticate(RequestEvent $event): void { $request = $event->getRequest(); @@ -97,10 +74,6 @@ public function authenticate(RequestEvent $event) private function createRedirectResponse(Request $request): RedirectResponse { - if (null !== $this->authenticationEntryPoint) { - return $this->authenticationEntryPoint->start($request); - } - $scheme = $request->isSecure() ? 'http' : 'https'; if ('http' === $scheme && 80 != $this->httpPort) { $port = ':'.$this->httpPort; diff --git a/Firewall/ContextListener.php b/Firewall/ContextListener.php index 06f2c390..05a4a84b 100644 --- a/Firewall/ContextListener.php +++ b/Firewall/ContextListener.php @@ -12,8 +12,6 @@ namespace Symfony\Component\Security\Http\Firewall; use Psr\Log\LoggerInterface; -use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -22,19 +20,17 @@ use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; -use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; -use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\User\EquatableInterface; +use Symfony\Component\Security\Core\User\LegacyPasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; -use Symfony\Component\Security\Http\Event\DeauthenticatedEvent; use Symfony\Component\Security\Http\Event\TokenDeauthenticatedEvent; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -47,38 +43,32 @@ */ class ContextListener extends AbstractListener { - private $tokenStorage; - private $sessionKey; - private $logger; - private $userProviders; - private $dispatcher; - private $registered; - private $trustResolver; - private $rememberMeServices; - private $sessionTrackerEnabler; + private string $sessionKey; + private bool $registered = false; + private AuthenticationTrustResolverInterface $trustResolver; + private ?\Closure $sessionTrackerEnabler; /** * @param iterable $userProviders */ - public function __construct(TokenStorageInterface $tokenStorage, iterable $userProviders, string $contextKey, ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null, ?AuthenticationTrustResolverInterface $trustResolver = null, ?callable $sessionTrackerEnabler = null) - { - if (empty($contextKey)) { + public function __construct( + private TokenStorageInterface $tokenStorage, + private iterable $userProviders, + string $contextKey, + private ?LoggerInterface $logger = null, + private ?EventDispatcherInterface $dispatcher = null, + ?AuthenticationTrustResolverInterface $trustResolver = null, + ?callable $sessionTrackerEnabler = null, + ) { + if (!$contextKey) { throw new \InvalidArgumentException('$contextKey must not be empty.'); } - $this->tokenStorage = $tokenStorage; - $this->userProviders = $userProviders; $this->sessionKey = '_security_'.$contextKey; - $this->logger = $logger; - $this->dispatcher = class_exists(Event::class) ? LegacyEventDispatcherProxy::decorate($dispatcher) : $dispatcher; - - $this->trustResolver = $trustResolver ?? new AuthenticationTrustResolver(AnonymousToken::class, RememberMeToken::class); - $this->sessionTrackerEnabler = $sessionTrackerEnabler; + $this->trustResolver = $trustResolver ?? new AuthenticationTrustResolver(); + $this->sessionTrackerEnabler = null === $sessionTrackerEnabler ? null : $sessionTrackerEnabler(...); } - /** - * {@inheritdoc} - */ public function supports(Request $request): ?bool { return null; // always run authenticate() lazily with lazy firewalls @@ -87,15 +77,15 @@ public function supports(Request $request): ?bool /** * Reads the Security Token from the session. */ - public function authenticate(RequestEvent $event) + public function authenticate(RequestEvent $event): void { if (!$this->registered && null !== $this->dispatcher && $event->isMainRequest()) { - $this->dispatcher->addListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']); + $this->dispatcher->addListener(KernelEvents::RESPONSE, $this->onKernelResponse(...)); $this->registered = true; } $request = $event->getRequest(); - $session = $request->hasPreviousSession() && $request->hasSession() ? $request->getSession() : null; + $session = $request->hasPreviousSession() ? $request->getSession() : null; $request->attributes->set('_security_firewall_run', $this->sessionKey); @@ -125,34 +115,26 @@ public function authenticate(RequestEvent $event) $token = $this->safelyUnserialize($token); - if (null !== $this->logger) { - $this->logger->debug('Read existing security token from the session.', [ - 'key' => $this->sessionKey, - 'token_class' => \is_object($token) ? \get_class($token) : null, - ]); - } + $this->logger?->debug('Read existing security token from the session.', [ + 'key' => $this->sessionKey, + 'token_class' => \is_object($token) ? $token::class : null, + ]); if ($token instanceof TokenInterface) { + if (!$token->getUser()) { + throw new \UnexpectedValueException(\sprintf('Cannot authenticate a "%s" token because it doesn\'t store a user.', $token::class)); + } + $originalToken = $token; $token = $this->refreshUser($token); if (!$token) { - if ($this->logger) { - $this->logger->debug('Token was deauthenticated after trying to refresh it.'); - } - - if ($this->dispatcher) { - $this->dispatcher->dispatch(new TokenDeauthenticatedEvent($originalToken, $request)); - } + $this->logger?->debug('Token was deauthenticated after trying to refresh it.'); - if ($this->rememberMeServices) { - $this->rememberMeServices->loginFail($request); - } + $this->dispatcher?->dispatch(new TokenDeauthenticatedEvent($originalToken, $request)); } } elseif (null !== $token) { - if (null !== $this->logger) { - $this->logger->warning('Expected a security token from the session, got something else.', ['key' => $this->sessionKey, 'received' => $token]); - } + $this->logger?->warning('Expected a security token from the session, got something else.', ['key' => $this->sessionKey, 'received' => $token]); $token = null; } @@ -167,7 +149,7 @@ public function authenticate(RequestEvent $event) /** * Writes the security token into the session. */ - public function onKernelResponse(ResponseEvent $event) + public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; @@ -179,31 +161,28 @@ public function onKernelResponse(ResponseEvent $event) return; } - if ($this->dispatcher) { - $this->dispatcher->removeListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']); - } + $this->dispatcher?->removeListener(KernelEvents::RESPONSE, $this->onKernelResponse(...)); $this->registered = false; $session = $request->getSession(); $sessionId = $session->getId(); $usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : null; + $usageIndexReference = \PHP_INT_MIN; $token = $this->tokenStorage->getToken(); - // @deprecated always use isAuthenticated() in 6.0 - $notAuthenticated = method_exists($this->trustResolver, 'isAuthenticated') ? !$this->trustResolver->isAuthenticated($token) : (null === $token || $this->trustResolver->isAnonymous($token)); - if ($notAuthenticated) { + if (!$this->trustResolver->isAuthenticated($token)) { if ($request->hasPreviousSession()) { $session->remove($this->sessionKey); } } else { $session->set($this->sessionKey, serialize($token)); - if (null !== $this->logger) { - $this->logger->debug('Stored the security token in the session.', ['key' => $this->sessionKey]); - } + $this->logger?->debug('Stored the security token in the session.', ['key' => $this->sessionKey]); } if ($this->sessionTrackerEnabler && $session->getId() === $sessionId) { $usageIndexReference = $usageIndexValue; + } else { + $usageIndexReference = $usageIndexReference - \PHP_INT_MIN + $usageIndexValue; } } @@ -212,20 +191,17 @@ public function onKernelResponse(ResponseEvent $event) * * @throws \RuntimeException */ - protected function refreshUser(TokenInterface $token): ?TokenInterface + private function refreshUser(TokenInterface $token): ?TokenInterface { $user = $token->getUser(); - if (!$user instanceof UserInterface) { - return $token; - } $userNotFoundByProvider = false; $userDeauthenticated = false; - $userClass = \get_class($user); + $userClass = $user::class; foreach ($this->userProviders as $provider) { if (!$provider instanceof UserProviderInterface) { - throw new \InvalidArgumentException(sprintf('User provider "%s" must implement "%s".', get_debug_type($provider), UserProviderInterface::class)); + throw new \InvalidArgumentException(\sprintf('User provider "%s" must implement "%s".', get_debug_type($provider), UserProviderInterface::class)); } if (!$provider->supportsClass($userClass)) { @@ -238,17 +214,10 @@ protected function refreshUser(TokenInterface $token): ?TokenInterface $newToken->setUser($refreshedUser, false); // tokens can be deauthenticated if the user has been changed. - if ($token instanceof AbstractToken && $this->hasUserChanged($user, $newToken)) { + if ($token instanceof AbstractToken && self::hasUserChanged($user, $newToken)) { $userDeauthenticated = true; - // @deprecated since Symfony 5.4 - if (method_exists($newToken, 'setAuthenticated')) { - $newToken->setAuthenticated(false, false); - } - if (null !== $this->logger) { - // @deprecated since Symfony 5.3, change to $refreshedUser->getUserIdentifier() in 6.0 - $this->logger->debug('Cannot refresh token because user has changed.', ['username' => method_exists($refreshedUser, 'getUserIdentifier') ? $refreshedUser->getUserIdentifier() : $refreshedUser->getUsername(), 'provider' => \get_class($provider)]); - } + $this->logger?->debug('Cannot refresh token because user has changed.', ['username' => $refreshedUser->getUserIdentifier(), 'provider' => $provider::class]); continue; } @@ -256,36 +225,27 @@ protected function refreshUser(TokenInterface $token): ?TokenInterface $token->setUser($refreshedUser); if (null !== $this->logger) { - // @deprecated since Symfony 5.3, change to $refreshedUser->getUserIdentifier() in 6.0 - $context = ['provider' => \get_class($provider), 'username' => method_exists($refreshedUser, 'getUserIdentifier') ? $refreshedUser->getUserIdentifier() : $refreshedUser->getUsername()]; + $context = ['provider' => $provider::class, 'username' => $refreshedUser->getUserIdentifier()]; if ($token instanceof SwitchUserToken) { $originalToken = $token->getOriginalToken(); - // @deprecated since Symfony 5.3, change to $originalToken->getUserIdentifier() in 6.0 - $context['impersonator_username'] = method_exists($originalToken, 'getUserIdentifier') ? $originalToken->getUserIdentifier() : $originalToken->getUsername(); + $context['impersonator_username'] = $originalToken->getUserIdentifier(); } $this->logger->debug('User was reloaded from a user provider.', $context); } return $token; - } catch (UnsupportedUserException $e) { + } catch (UnsupportedUserException) { // let's try the next user provider } catch (UserNotFoundException $e) { - if (null !== $this->logger) { - $this->logger->warning('Username could not be found in the selected user provider.', ['username' => method_exists($e, 'getUserIdentifier') ? $e->getUserIdentifier() : $e->getUsername(), 'provider' => \get_class($provider)]); - } + $this->logger?->info('Username could not be found in the selected user provider.', ['username' => $e->getUserIdentifier(), 'provider' => $provider::class]); $userNotFoundByProvider = true; } } if ($userDeauthenticated) { - // @deprecated since Symfony 5.4 - if ($this->dispatcher) { - $this->dispatcher->dispatch(new DeauthenticatedEvent($token, $newToken, false), DeauthenticatedEvent::class); - } - return null; } @@ -293,10 +253,10 @@ protected function refreshUser(TokenInterface $token): ?TokenInterface return null; } - throw new \RuntimeException(sprintf('There is no user provider for user "%s". Shouldn\'t the "supportsClass()" method of your user provider return true for this classname?', $userClass)); + throw new \RuntimeException(\sprintf('There is no user provider for user "%s". Shouldn\'t the "supportsClass()" method of your user provider return true for this classname?', $userClass)); } - private function safelyUnserialize(string $serializedToken) + private function safelyUnserialize(string $serializedToken): mixed { $token = null; $prevUnserializeHandler = ini_set('unserialize_callback_func', __CLASS__.'::handleUnserializeCallback'); @@ -314,9 +274,7 @@ private function safelyUnserialize(string $serializedToken) if (0x37313BC !== $e->getCode()) { throw $e; } - if ($this->logger) { - $this->logger->warning('Failed to unserialize the security token from the session.', ['key' => $this->sessionKey, 'received' => $serializedToken, 'exception' => $e]); - } + $this->logger?->warning('Failed to unserialize the security token from the session.', ['key' => $this->sessionKey, 'received' => $serializedToken, 'exception' => $e]); } finally { restore_error_handler(); ini_set('unserialize_callback_func', $prevUnserializeHandler); @@ -325,57 +283,49 @@ private function safelyUnserialize(string $serializedToken) return $token; } - /** - * @param string|\Stringable|UserInterface $originalUser - */ - private static function hasUserChanged($originalUser, TokenInterface $refreshedToken): bool + private static function hasUserChanged(UserInterface $originalUser, TokenInterface $refreshedToken): bool { $refreshedUser = $refreshedToken->getUser(); - if ($originalUser instanceof UserInterface) { - if (!$refreshedUser instanceof UserInterface) { - return true; - } else { - // noop - } - } elseif ($refreshedUser instanceof UserInterface) { - return true; - } else { - return (string) $originalUser !== (string) $refreshedUser; - } - if ($originalUser instanceof EquatableInterface) { - return !(bool) $originalUser->isEqualTo($refreshedUser); + return !$originalUser->isEqualTo($refreshedUser); } - // @deprecated since Symfony 5.3, check for PasswordAuthenticatedUserInterface on both user objects before comparing passwords - if ($originalUser->getPassword() !== $refreshedUser->getPassword()) { - return true; - } + if ($originalUser instanceof PasswordAuthenticatedUserInterface || $refreshedUser instanceof PasswordAuthenticatedUserInterface) { + if (!$originalUser instanceof PasswordAuthenticatedUserInterface || !$refreshedUser instanceof PasswordAuthenticatedUserInterface) { + return true; + } - // @deprecated since Symfony 5.3, check for LegacyPasswordAuthenticatedUserInterface on both user objects before comparing salts - if ($originalUser->getSalt() !== $refreshedUser->getSalt()) { - return true; - } + $originalPassword = $originalUser->getPassword(); + $refreshedPassword = $refreshedUser->getPassword(); + + if (null !== $originalPassword + && $refreshedPassword !== $originalPassword + && (8 !== \strlen($originalPassword) || hash('crc32c', $refreshedPassword ?? $originalPassword) !== $originalPassword) + ) { + return true; + } - $userRoles = array_map('strval', (array) $refreshedUser->getRoles()); + if ($originalUser instanceof LegacyPasswordAuthenticatedUserInterface xor $refreshedUser instanceof LegacyPasswordAuthenticatedUserInterface) { + return true; + } - if ($refreshedToken instanceof SwitchUserToken) { - $userRoles[] = 'ROLE_PREVIOUS_ADMIN'; + if ($originalUser instanceof LegacyPasswordAuthenticatedUserInterface && $originalUser->getSalt() !== $refreshedUser->getSalt()) { + return true; + } } + $refreshedRoles = array_map('strval', $refreshedUser->getRoles()); + $originalRoles = $refreshedToken->getRoleNames(); // This comes from cloning the original token, so it still contains the roles of the original user + if ( - \count($userRoles) !== \count($refreshedToken->getRoleNames()) || - \count($userRoles) !== \count(array_intersect($userRoles, $refreshedToken->getRoleNames())) + \count($refreshedRoles) !== \count($originalRoles) + || \count($refreshedRoles) !== \count(array_intersect($refreshedRoles, $originalRoles)) ) { return true; } - // @deprecated since Symfony 5.3, drop getUsername() in 6.0 - $userIdentifier = function ($refreshedUser) { - return method_exists($refreshedUser, 'getUserIdentifier') ? $refreshedUser->getUserIdentifier() : $refreshedUser->getUsername(); - }; - if ($userIdentifier($originalUser) !== $userIdentifier($refreshedUser)) { + if ($originalUser->getUserIdentifier() !== $refreshedUser->getUserIdentifier()) { return true; } @@ -385,18 +335,8 @@ private static function hasUserChanged($originalUser, TokenInterface $refreshedT /** * @internal */ - public static function handleUnserializeCallback(string $class) + public static function handleUnserializeCallback(string $class): never { throw new \ErrorException('Class not found: '.$class, 0x37313BC); } - - /** - * @deprecated since Symfony 5.4 - */ - public function setRememberMeServices(RememberMeServicesInterface $rememberMeServices) - { - trigger_deprecation('symfony/security-http', '5.4', 'Method "%s()" is deprecated, use the new remember me handlers instead.', __METHOD__); - - $this->rememberMeServices = $rememberMeServices; - } } diff --git a/Firewall/ExceptionListener.php b/Firewall/ExceptionListener.php index 6ff46c55..a85ff958 100644 --- a/Firewall/ExceptionListener.php +++ b/Firewall/ExceptionListener.php @@ -28,11 +28,11 @@ use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException; use Symfony\Component\Security\Core\Exception\LazyResponseException; use Symfony\Component\Security\Core\Exception\LogoutException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException; use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\SecurityRequestAttributes; use Symfony\Component\Security\Http\Util\TargetPathTrait; /** @@ -47,49 +47,39 @@ class ExceptionListener { use TargetPathTrait; - private $tokenStorage; - private $firewallName; - private $accessDeniedHandler; - private $authenticationEntryPoint; - private $authenticationTrustResolver; - private $errorPage; - private $logger; - private $httpUtils; - private $stateless; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationTrustResolverInterface $trustResolver, HttpUtils $httpUtils, string $firewallName, ?AuthenticationEntryPointInterface $authenticationEntryPoint = null, ?string $errorPage = null, ?AccessDeniedHandlerInterface $accessDeniedHandler = null, ?LoggerInterface $logger = null, bool $stateless = false) - { - $this->tokenStorage = $tokenStorage; - $this->accessDeniedHandler = $accessDeniedHandler; - $this->httpUtils = $httpUtils; - $this->firewallName = $firewallName; - $this->authenticationEntryPoint = $authenticationEntryPoint; - $this->authenticationTrustResolver = $trustResolver; - $this->errorPage = $errorPage; - $this->logger = $logger; - $this->stateless = $stateless; + public function __construct( + private TokenStorageInterface $tokenStorage, + private AuthenticationTrustResolverInterface $authenticationTrustResolver, + private HttpUtils $httpUtils, + private string $firewallName, + private ?AuthenticationEntryPointInterface $authenticationEntryPoint = null, + private ?string $errorPage = null, + private ?AccessDeniedHandlerInterface $accessDeniedHandler = null, + private ?LoggerInterface $logger = null, + private bool $stateless = false, + ) { } /** * Registers a onKernelException listener to take care of security exceptions. */ - public function register(EventDispatcherInterface $dispatcher) + public function register(EventDispatcherInterface $dispatcher): void { - $dispatcher->addListener(KernelEvents::EXCEPTION, [$this, 'onKernelException'], 1); + $dispatcher->addListener(KernelEvents::EXCEPTION, $this->onKernelException(...), 1); } /** * Unregisters the dispatcher. */ - public function unregister(EventDispatcherInterface $dispatcher) + public function unregister(EventDispatcherInterface $dispatcher): void { - $dispatcher->removeListener(KernelEvents::EXCEPTION, [$this, 'onKernelException']); + $dispatcher->removeListener(KernelEvents::EXCEPTION, $this->onKernelException(...)); } /** * Handles security related exceptions. */ - public function onKernelException(ExceptionEvent $event) + public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); do { @@ -121,9 +111,7 @@ public function onKernelException(ExceptionEvent $event) private function handleAuthenticationException(ExceptionEvent $event, AuthenticationException $exception): void { - if (null !== $this->logger) { - $this->logger->info('An AuthenticationException was thrown; redirecting to authentication entry point.', ['exception' => $exception]); - } + $this->logger?->info('An AuthenticationException was thrown; redirecting to authentication entry point.', ['exception' => $exception]); try { $event->setResponse($this->startAuthentication($event->getRequest(), $exception)); @@ -133,15 +121,13 @@ private function handleAuthenticationException(ExceptionEvent $event, Authentica } } - private function handleAccessDeniedException(ExceptionEvent $event, AccessDeniedException $exception) + private function handleAccessDeniedException(ExceptionEvent $event, AccessDeniedException $exception): void { $event->setThrowable(new AccessDeniedHttpException($exception->getMessage(), $exception)); $token = $this->tokenStorage->getToken(); if (!$this->authenticationTrustResolver->isFullFledged($token)) { - if (null !== $this->logger) { - $this->logger->debug('Access denied, the user is not fully authenticated; redirecting to authentication entry point.', ['exception' => $exception]); - } + $this->logger?->debug('Access denied, the user is not fully authenticated; redirecting to authentication entry point.', ['exception' => $exception]); try { $insufficientAuthenticationException = new InsufficientAuthenticationException('Full authentication is required to access this resource.', 0, $exception); @@ -157,9 +143,7 @@ private function handleAccessDeniedException(ExceptionEvent $event, AccessDenied return; } - if (null !== $this->logger) { - $this->logger->debug('Access denied, the user is neither anonymous, nor remember-me.', ['exception' => $exception]); - } + $this->logger?->debug('Access denied, the user is neither anonymous, nor remember-me.', ['exception' => $exception]); try { if (null !== $this->accessDeniedHandler) { @@ -170,15 +154,13 @@ private function handleAccessDeniedException(ExceptionEvent $event, AccessDenied } } elseif (null !== $this->errorPage) { $subRequest = $this->httpUtils->createRequest($event->getRequest(), $this->errorPage); - $subRequest->attributes->set(Security::ACCESS_DENIED_ERROR, $exception); + $subRequest->attributes->set(SecurityRequestAttributes::ACCESS_DENIED_ERROR, $exception); $event->setResponse($event->getKernel()->handle($subRequest, HttpKernelInterface::SUB_REQUEST, true)); $event->allowCustomResponseCode(); } } catch (\Exception $e) { - if (null !== $this->logger) { - $this->logger->error('An exception was thrown when handling an AccessDeniedException.', ['exception' => $e]); - } + $this->logger?->error('An exception was thrown when handling an AccessDeniedException.', ['exception' => $e]); $event->setThrowable(new \RuntimeException('Exception thrown when handling an exception.', 0, $e)); } @@ -188,9 +170,7 @@ private function handleLogoutException(ExceptionEvent $event, LogoutException $e { $event->setThrowable(new AccessDeniedHttpException($exception->getMessage(), $exception)); - if (null !== $this->logger) { - $this->logger->info('A LogoutException was thrown; wrapping with AccessDeniedHttpException', ['exception' => $exception]); - } + $this->logger?->info('A LogoutException was thrown; wrapping with AccessDeniedHttpException', ['exception' => $exception]); } private function startAuthentication(Request $request, AuthenticationException $authException): Response @@ -199,9 +179,7 @@ private function startAuthentication(Request $request, AuthenticationException $ $this->throwUnauthorizedException($authException); } - if (null !== $this->logger) { - $this->logger->debug('Calling Authentication entry point.'); - } + $this->logger?->debug('Calling Authentication entry point.', ['entry_point' => $this->authenticationEntryPoint]); if (!$this->stateless) { $this->setTargetPath($request); @@ -211,27 +189,19 @@ private function startAuthentication(Request $request, AuthenticationException $ // remove the security token to prevent infinite redirect loops $this->tokenStorage->setToken(null); - if (null !== $this->logger) { - $this->logger->info('The security token was removed due to an AccountStatusException.', ['exception' => $authException]); - } + $this->logger?->info('The security token was removed due to an AccountStatusException.', ['exception' => $authException]); } try { $response = $this->authenticationEntryPoint->start($request, $authException); - } catch (NotAnEntryPointException $e) { + } catch (NotAnEntryPointException) { $this->throwUnauthorizedException($authException); } - if (!$response instanceof Response) { - $given = get_debug_type($response); - - throw new \LogicException(sprintf('The "%s::start()" method must return a Response object ("%s" returned).', get_debug_type($this->authenticationEntryPoint), $given)); - } - return $response; } - protected function setTargetPath(Request $request) + protected function setTargetPath(Request $request): void { // session isn't required when using HTTP basic authentication mechanism for example if ($request->hasSession() && $request->isMethodSafe() && !$request->isXmlHttpRequest()) { @@ -239,11 +209,9 @@ protected function setTargetPath(Request $request) } } - private function throwUnauthorizedException(AuthenticationException $authException) + private function throwUnauthorizedException(AuthenticationException $authException): never { - if (null !== $this->logger) { - $this->logger->notice(sprintf('No Authentication entry point configured, returning a %s HTTP response. Configure "entry_point" on the firewall "%s" if you want to modify the response.', Response::HTTP_UNAUTHORIZED, $this->firewallName)); - } + $this->logger?->notice(\sprintf('No Authentication entry point configured, returning a %s HTTP response. Configure "entry_point" on the firewall "%s" if you want to modify the response.', Response::HTTP_UNAUTHORIZED, $this->firewallName)); throw new HttpException(Response::HTTP_UNAUTHORIZED, $authException->getMessage(), $authException, [], $authException->getCode()); } diff --git a/Firewall/FirewallListenerInterface.php b/Firewall/FirewallListenerInterface.php index 485d767c..27f1e1bb 100644 --- a/Firewall/FirewallListenerInterface.php +++ b/Firewall/FirewallListenerInterface.php @@ -33,7 +33,7 @@ public function supports(Request $request): ?bool; /** * Does whatever is required to authenticate the request, typically calling $event->setResponse() internally. */ - public function authenticate(RequestEvent $event); + public function authenticate(RequestEvent $event): void; /** * Defines the priority of the listener. diff --git a/Firewall/LogoutListener.php b/Firewall/LogoutListener.php index e91d23df..13a88dd4 100644 --- a/Firewall/LogoutListener.php +++ b/Firewall/LogoutListener.php @@ -11,19 +11,14 @@ namespace Symfony\Component\Security\Http\Firewall; -use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface; -use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; use Symfony\Component\Security\Http\ParameterBagUtils; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -36,62 +31,25 @@ */ class LogoutListener extends AbstractListener { - private $tokenStorage; - private $options; - private $httpUtils; - private $csrfTokenManager; - private $eventDispatcher; + private array $options; /** - * @param EventDispatcherInterface $eventDispatcher - * @param array $options An array of options to process a logout attempt + * @param array $options An array of options to process a logout attempt */ - public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, $eventDispatcher, array $options = [], ?CsrfTokenManagerInterface $csrfTokenManager = null) - { - if (!$eventDispatcher instanceof EventDispatcherInterface) { - trigger_deprecation('symfony/security-http', '5.1', 'Passing a logout success handler to "%s" is deprecated, pass an instance of "%s" instead.', __METHOD__, EventDispatcherInterface::class); - - if (!$eventDispatcher instanceof LogoutSuccessHandlerInterface) { - throw new \TypeError(sprintf('Argument 3 of "%s" must be instance of "%s" or "%s", "%s" given.', __METHOD__, EventDispatcherInterface::class, LogoutSuccessHandlerInterface::class, get_debug_type($eventDispatcher))); - } - - $successHandler = $eventDispatcher; - $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use ($successHandler) { - $event->setResponse($r = $successHandler->onLogoutSuccess($event->getRequest())); - }); - } - - $this->tokenStorage = $tokenStorage; - $this->httpUtils = $httpUtils; + public function __construct( + private TokenStorageInterface $tokenStorage, + private HttpUtils $httpUtils, + private EventDispatcherInterface $eventDispatcher, + array $options = [], + private ?CsrfTokenManagerInterface $csrfTokenManager = null, + ) { $this->options = array_merge([ 'csrf_parameter' => '_csrf_token', 'csrf_token_id' => 'logout', 'logout_path' => '/logout', ], $options); - $this->csrfTokenManager = $csrfTokenManager; - $this->eventDispatcher = $eventDispatcher; - } - - /** - * @deprecated since Symfony 5.1 - */ - public function addHandler(LogoutHandlerInterface $handler) - { - trigger_deprecation('symfony/security-http', '5.1', 'Calling "%s" is deprecated, register a listener on the "%s" event instead.', __METHOD__, LogoutEvent::class); - - $this->eventDispatcher->addListener(LogoutEvent::class, function (LogoutEvent $event) use ($handler) { - if (null === $event->getResponse()) { - throw new LogicException(sprintf('No response was set for this logout action. Make sure the DefaultLogoutListener or another listener has set the response before "%s" is called.', __CLASS__)); - } - - $handler->logout($event->getRequest(), $event->getResponse(), $event->getToken()); - }); } - /** - * {@inheritdoc} - */ public function supports(Request $request): ?bool { return $this->requiresLogout($request); @@ -104,9 +62,9 @@ public function supports(Request $request): ?bool * validate the request. * * @throws LogoutException if the CSRF token is invalid - * @throws \RuntimeException if the LogoutSuccessHandlerInterface instance does not return a response + * @throws \RuntimeException if the LogoutEvent listener does not set a response */ - public function authenticate(RequestEvent $event) + public function authenticate(RequestEvent $event): void { $request = $event->getRequest(); diff --git a/Firewall/RememberMeListener.php b/Firewall/RememberMeListener.php deleted file mode 100644 index 53fec687..00000000 --- a/Firewall/RememberMeListener.php +++ /dev/null @@ -1,130 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; -use Symfony\Component\Security\Http\SecurityEvents; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', RememberMeListener::class); - -/** - * RememberMeListener implements authentication capabilities via a cookie. - * - * @author Johannes M. Schmitt - * - * @final - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class RememberMeListener extends AbstractListener -{ - private $tokenStorage; - private $rememberMeServices; - private $authenticationManager; - private $logger; - private $dispatcher; - private $catchExceptions = true; - private $sessionStrategy; - - public function __construct(TokenStorageInterface $tokenStorage, RememberMeServicesInterface $rememberMeServices, AuthenticationManagerInterface $authenticationManager, ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null, bool $catchExceptions = true, ?SessionAuthenticationStrategyInterface $sessionStrategy = null) - { - $this->tokenStorage = $tokenStorage; - $this->rememberMeServices = $rememberMeServices; - $this->authenticationManager = $authenticationManager; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->catchExceptions = $catchExceptions; - $this->sessionStrategy = $sessionStrategy ?? new SessionAuthenticationStrategy(SessionAuthenticationStrategy::MIGRATE); - } - - /** - * {@inheritdoc} - */ - public function supports(Request $request): ?bool - { - return null; // always run authenticate() lazily with lazy firewalls - } - - /** - * Handles remember-me cookie based authentication. - */ - public function authenticate(RequestEvent $event) - { - if (null !== $this->tokenStorage->getToken()) { - return; - } - - $request = $event->getRequest(); - try { - if (null === $token = $this->rememberMeServices->autoLogin($request)) { - return; - } - } catch (AuthenticationException $e) { - if (null !== $this->logger) { - $this->logger->warning( - 'The token storage was not populated with remember-me token as the' - .' RememberMeServices was not able to create a token from the remember' - .' me information.', ['exception' => $e] - ); - } - - $this->rememberMeServices->loginFail($request); - - if (!$this->catchExceptions) { - throw $e; - } - - return; - } - - try { - $token = $this->authenticationManager->authenticate($token); - if ($request->hasSession() && $request->getSession()->isStarted()) { - $this->sessionStrategy->onAuthentication($request, $token); - } - $this->tokenStorage->setToken($token); - - if (null !== $this->dispatcher) { - $loginEvent = new InteractiveLoginEvent($request, $token); - $this->dispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN); - } - - if (null !== $this->logger) { - $this->logger->debug('Populated the token storage with a remember-me token.'); - } - } catch (AuthenticationException $e) { - if (null !== $this->logger) { - $this->logger->warning( - 'The token storage was not populated with remember-me token as the' - .' AuthenticationManager rejected the AuthenticationToken returned' - .' by the RememberMeServices.', ['exception' => $e] - ); - } - - $this->rememberMeServices->loginFail($request, $e); - - if (!$this->catchExceptions) { - throw $e; - } - } - } -} diff --git a/Firewall/RemoteUserAuthenticationListener.php b/Firewall/RemoteUserAuthenticationListener.php deleted file mode 100644 index bde31412..00000000 --- a/Firewall/RemoteUserAuthenticationListener.php +++ /dev/null @@ -1,53 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', RemoteUserAuthenticationListener::class); - -/** - * REMOTE_USER authentication listener. - * - * @author Fabien Potencier - * @author Maxime Douailin - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class RemoteUserAuthenticationListener extends AbstractPreAuthenticatedListener -{ - private $userKey; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, string $providerKey, string $userKey = 'REMOTE_USER', ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null) - { - parent::__construct($tokenStorage, $authenticationManager, $providerKey, $logger, $dispatcher); - - $this->userKey = $userKey; - } - - /** - * {@inheritdoc} - */ - protected function getPreAuthenticatedData(Request $request) - { - if (!$request->server->has($this->userKey)) { - throw new BadCredentialsException(sprintf('User key was not found: "%s".', $this->userKey)); - } - - return [$request->server->get($this->userKey), null]; - } -} diff --git a/Firewall/SwitchUserListener.php b/Firewall/SwitchUserListener.php index 52a4ac3c..8c03e856 100644 --- a/Firewall/SwitchUserListener.php +++ b/Firewall/SwitchUserListener.php @@ -15,9 +15,11 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; @@ -41,38 +43,25 @@ class SwitchUserListener extends AbstractListener { public const EXIT_VALUE = '_exit'; - private $tokenStorage; - private $provider; - private $userChecker; - private $firewallName; - private $accessDecisionManager; - private $usernameParameter; - private $role; - private $logger; - private $dispatcher; - private $stateless; - - public function __construct(TokenStorageInterface $tokenStorage, UserProviderInterface $provider, UserCheckerInterface $userChecker, string $firewallName, AccessDecisionManagerInterface $accessDecisionManager, ?LoggerInterface $logger = null, string $usernameParameter = '_switch_user', string $role = 'ROLE_ALLOWED_TO_SWITCH', ?EventDispatcherInterface $dispatcher = null, bool $stateless = false) - { + public function __construct( + private TokenStorageInterface $tokenStorage, + private UserProviderInterface $provider, + private UserCheckerInterface $userChecker, + private string $firewallName, + private AccessDecisionManagerInterface $accessDecisionManager, + private ?LoggerInterface $logger = null, + private string $usernameParameter = '_switch_user', + private string $role = 'ROLE_ALLOWED_TO_SWITCH', + private ?EventDispatcherInterface $dispatcher = null, + private bool $stateless = false, + private ?UrlGeneratorInterface $urlGenerator = null, + private ?string $targetRoute = null, + ) { if ('' === $firewallName) { throw new \InvalidArgumentException('$firewallName must not be empty.'); } - - $this->tokenStorage = $tokenStorage; - $this->provider = $provider; - $this->userChecker = $userChecker; - $this->firewallName = $firewallName; - $this->accessDecisionManager = $accessDecisionManager; - $this->usernameParameter = $usernameParameter; - $this->role = $role; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->stateless = $stateless; } - /** - * {@inheritdoc} - */ public function supports(Request $request): ?bool { // usernames can be falsy @@ -97,7 +86,7 @@ public function supports(Request $request): ?bool * * @throws \LogicException if switching to a user failed */ - public function authenticate(RequestEvent $event) + public function authenticate(RequestEvent $event): void { $request = $event->getRequest(); @@ -109,7 +98,7 @@ public function authenticate(RequestEvent $event) } if (self::EXIT_VALUE === $username) { - $this->tokenStorage->setToken($this->attemptExitUser($request)); + $this->attemptExitUser($request); } else { try { $this->tokenStorage->setToken($this->attemptSwitchUser($request, $username)); @@ -122,7 +111,7 @@ public function authenticate(RequestEvent $event) if (!$this->stateless) { $request->query->remove($this->usernameParameter); $request->server->set('QUERY_STRING', http_build_query($request->query->all(), '', '&')); - $response = new RedirectResponse($request->getUri(), 302); + $response = new RedirectResponse($this->urlGenerator && $this->targetRoute ? $this->urlGenerator->generate($this->targetRoute) : $request->getUri(), 302); $event->setResponse($response); } @@ -140,8 +129,7 @@ private function attemptSwitchUser(Request $request, string $username): ?TokenIn $originalToken = $this->getOriginalToken($token); if (null !== $originalToken) { - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - if ((method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername()) === $username) { + if ($token->getUserIdentifier() === $username) { return $token; } @@ -149,46 +137,38 @@ private function attemptSwitchUser(Request $request, string $username): ?TokenIn $token = $this->attemptExitUser($request); } - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - $currentUsername = method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(); - $nonExistentUsername = '_'.md5(random_bytes(8).$username); + $currentUsername = $token->getUserIdentifier(); + $nonExistentUsername = '_'.hash('xxh128', random_bytes(8).$username); // To protect against user enumeration via timing measurements // we always load both successfully and unsuccessfully - $methodName = 'loadUserByIdentifier'; - if (!method_exists($this->provider, $methodName)) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->provider)); - - $methodName = 'loadUserByUsername'; - } try { - $user = $this->provider->$methodName($username); + $user = $this->provider->loadUserByIdentifier($username); try { - $this->provider->$methodName($nonExistentUsername); - } catch (\Exception $e) { + $this->provider->loadUserByIdentifier($nonExistentUsername); + } catch (\Exception) { } } catch (AuthenticationException $e) { - $this->provider->$methodName($currentUsername); + $this->provider->loadUserByIdentifier($currentUsername); throw $e; } + $accessDecision = new AccessDecision(); - if (false === $this->accessDecisionManager->decide($token, [$this->role], $user)) { - $exception = new AccessDeniedException(); - $exception->setAttributes($this->role); + if (!$accessDecision->isGranted = $this->accessDecisionManager->decide($token, [$this->role], $user, $accessDecision)) { + $e = new AccessDeniedException($accessDecision->getMessage()); + $e->setAttributes($this->role); + $e->setAccessDecision($accessDecision); - throw $exception; + throw $e; } - if (null !== $this->logger) { - $this->logger->info('Attempting to switch to user.', ['username' => $username]); - } + $this->logger?->info('Attempting to switch to user.', ['username' => $username]); - $this->userChecker->checkPostAuth($user); + $this->userChecker->checkPostAuth($user, $token); $roles = $user->getRoles(); - $roles[] = 'ROLE_PREVIOUS_ADMIN'; $originatedFromUri = str_replace('/&', '/?', preg_replace('#[&?]'.$this->usernameParameter.'=[^&]*#', '', $request->getRequestUri())); $token = new SwitchUserToken($user, $this->firewallName, $roles, $token, $originatedFromUri); @@ -221,6 +201,8 @@ private function attemptExitUser(Request $request): TokenInterface $original = $switchEvent->getToken(); } + $this->tokenStorage->setToken($original); + return $original; } diff --git a/Firewall/UsernamePasswordFormAuthenticationListener.php b/Firewall/UsernamePasswordFormAuthenticationListener.php deleted file mode 100644 index eecc6571..00000000 --- a/Firewall/UsernamePasswordFormAuthenticationListener.php +++ /dev/null @@ -1,110 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; -use Symfony\Component\Security\Core\Security; -use Symfony\Component\Security\Csrf\CsrfToken; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; -use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; -use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\ParameterBagUtils; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', UsernamePasswordFormAuthenticationListener::class); - -/** - * UsernamePasswordFormAuthenticationListener is the default implementation of - * an authentication via a simple form composed of a username and a password. - * - * @author Fabien Potencier - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class UsernamePasswordFormAuthenticationListener extends AbstractAuthenticationListener -{ - private $csrfTokenManager; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, string $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = [], ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null, ?CsrfTokenManagerInterface $csrfTokenManager = null) - { - parent::__construct($tokenStorage, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, array_merge([ - 'username_parameter' => '_username', - 'password_parameter' => '_password', - 'csrf_parameter' => '_csrf_token', - 'csrf_token_id' => 'authenticate', - 'post_only' => true, - ], $options), $logger, $dispatcher); - - $this->csrfTokenManager = $csrfTokenManager; - } - - /** - * {@inheritdoc} - */ - protected function requiresAuthentication(Request $request) - { - if ($this->options['post_only'] && !$request->isMethod('POST')) { - return false; - } - - return parent::requiresAuthentication($request); - } - - /** - * {@inheritdoc} - */ - protected function attemptAuthentication(Request $request) - { - if (null !== $this->csrfTokenManager) { - $csrfToken = ParameterBagUtils::getRequestParameterValue($request, $this->options['csrf_parameter']); - - if (!\is_string($csrfToken) || false === $this->csrfTokenManager->isTokenValid(new CsrfToken($this->options['csrf_token_id'], $csrfToken))) { - throw new InvalidCsrfTokenException('Invalid CSRF token.'); - } - } - - if ($this->options['post_only']) { - $username = ParameterBagUtils::getParameterBagValue($request->request, $this->options['username_parameter']); - $password = ParameterBagUtils::getParameterBagValue($request->request, $this->options['password_parameter']); - } else { - $username = ParameterBagUtils::getRequestParameterValue($request, $this->options['username_parameter']); - $password = ParameterBagUtils::getRequestParameterValue($request, $this->options['password_parameter']); - } - - if (!\is_string($username) && (!\is_object($username) || !method_exists($username, '__toString'))) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], get_debug_type($username))); - } - - $username = trim($username); - - if (\strlen($username) > Security::MAX_USERNAME_LENGTH) { - throw new BadCredentialsException('Invalid username.'); - } - - if (null === $password) { - throw new \LogicException(sprintf('The key "%s" cannot be null; check that the password field name of the form matches.', $this->options['password_parameter'])); - } - - $request->getSession()->set(Security::LAST_USERNAME, $username); - - return $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $password, $this->providerKey)); - } -} diff --git a/Firewall/UsernamePasswordJsonAuthenticationListener.php b/Firewall/UsernamePasswordJsonAuthenticationListener.php deleted file mode 100644 index f057d10e..00000000 --- a/Firewall/UsernamePasswordJsonAuthenticationListener.php +++ /dev/null @@ -1,245 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\PropertyAccess\Exception\AccessException; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Security; -use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; -use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; -use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\SecurityEvents; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -use Symfony\Contracts\Translation\TranslatorInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', UsernamePasswordJsonAuthenticationListener::class); - -/** - * UsernamePasswordJsonAuthenticationListener is a stateless implementation of - * an authentication via a JSON document composed of a username and a password. - * - * @author Kévin Dunglas - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class UsernamePasswordJsonAuthenticationListener extends AbstractListener -{ - private $tokenStorage; - private $authenticationManager; - private $httpUtils; - private $providerKey; - private $successHandler; - private $failureHandler; - private $options; - private $logger; - private $eventDispatcher; - private $propertyAccessor; - private $sessionStrategy; - - /** - * @var TranslatorInterface|null - */ - private $translator; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, HttpUtils $httpUtils, string $providerKey, ?AuthenticationSuccessHandlerInterface $successHandler = null, ?AuthenticationFailureHandlerInterface $failureHandler = null, array $options = [], ?LoggerInterface $logger = null, ?EventDispatcherInterface $eventDispatcher = null, ?PropertyAccessorInterface $propertyAccessor = null) - { - $this->tokenStorage = $tokenStorage; - $this->authenticationManager = $authenticationManager; - $this->httpUtils = $httpUtils; - $this->providerKey = $providerKey; - $this->successHandler = $successHandler; - $this->failureHandler = $failureHandler; - $this->logger = $logger; - $this->eventDispatcher = $eventDispatcher; - $this->options = array_merge(['username_path' => 'username', 'password_path' => 'password'], $options); - $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); - } - - public function supports(Request $request): ?bool - { - if (!str_contains($request->getRequestFormat() ?? '', 'json') - && !str_contains($request->getContentType() ?? '', 'json') - ) { - return false; - } - - if (isset($this->options['check_path']) && !$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) { - return false; - } - - return true; - } - - /** - * {@inheritdoc} - */ - public function authenticate(RequestEvent $event) - { - $request = $event->getRequest(); - $data = json_decode($request->getContent()); - $previousToken = $this->tokenStorage->getToken(); - - try { - if (!$data instanceof \stdClass) { - throw new BadRequestHttpException('Invalid JSON.'); - } - - try { - $username = $this->propertyAccessor->getValue($data, $this->options['username_path']); - } catch (AccessException $e) { - throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['username_path']), $e); - } - - try { - $password = $this->propertyAccessor->getValue($data, $this->options['password_path']); - } catch (AccessException $e) { - throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['password_path']), $e); - } - - if (!\is_string($username)) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['username_path'])); - } - - if (\strlen($username) > Security::MAX_USERNAME_LENGTH) { - throw new BadCredentialsException('Invalid username.'); - } - - if (!\is_string($password)) { - throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['password_path'])); - } - - $token = new UsernamePasswordToken($username, $password, $this->providerKey); - - $authenticatedToken = $this->authenticationManager->authenticate($token); - $response = $this->onSuccess($request, $authenticatedToken, $previousToken); - } catch (AuthenticationException $e) { - $response = $this->onFailure($request, $e); - } catch (BadRequestHttpException $e) { - $request->setRequestFormat('json'); - - throw $e; - } - - if (null === $response) { - return; - } - - $event->setResponse($response); - } - - private function onSuccess(Request $request, TokenInterface $token, ?TokenInterface $previousToken): ?Response - { - if (null !== $this->logger) { - // @deprecated since Symfony 5.3, change to $token->getUserIdentifier() in 6.0 - $this->logger->info('User has been authenticated successfully.', ['username' => method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername()]); - } - - $this->migrateSession($request, $token, $previousToken); - - $this->tokenStorage->setToken($token); - - if (null !== $this->eventDispatcher) { - $loginEvent = new InteractiveLoginEvent($request, $token); - $this->eventDispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN); - } - - if (!$this->successHandler) { - return null; // let the original request succeeds - } - - $response = $this->successHandler->onAuthenticationSuccess($request, $token); - - if (!$response instanceof Response) { - throw new \RuntimeException('Authentication Success Handler did not return a Response.'); - } - - return $response; - } - - private function onFailure(Request $request, AuthenticationException $failed): Response - { - if (null !== $this->logger) { - $this->logger->info('Authentication request failed.', ['exception' => $failed]); - } - - $token = $this->tokenStorage->getToken(); - if ($token instanceof UsernamePasswordToken && $this->providerKey === $token->getFirewallName()) { - $this->tokenStorage->setToken(null); - } - - if (!$this->failureHandler) { - if (null !== $this->translator) { - $errorMessage = $this->translator->trans($failed->getMessageKey(), $failed->getMessageData(), 'security'); - } else { - $errorMessage = strtr($failed->getMessageKey(), $failed->getMessageData()); - } - - return new JsonResponse(['error' => $errorMessage], 401); - } - - $response = $this->failureHandler->onAuthenticationFailure($request, $failed); - - if (!$response instanceof Response) { - throw new \RuntimeException('Authentication Failure Handler did not return a Response.'); - } - - return $response; - } - - /** - * Call this method if your authentication token is stored to a session. - * - * @final - */ - public function setSessionAuthenticationStrategy(SessionAuthenticationStrategyInterface $sessionStrategy) - { - $this->sessionStrategy = $sessionStrategy; - } - - public function setTranslator(TranslatorInterface $translator) - { - $this->translator = $translator; - } - - private function migrateSession(Request $request, TokenInterface $token, ?TokenInterface $previousToken) - { - if (!$this->sessionStrategy || !$request->hasSession() || !$request->hasPreviousSession()) { - return; - } - - if ($previousToken) { - $user = method_exists($token, 'getUserIdentifier') ? $token->getUserIdentifier() : $token->getUsername(); - $previousUser = method_exists($previousToken, 'getUserIdentifier') ? $previousToken->getUserIdentifier() : $previousToken->getUsername(); - - if ('' !== ($user ?? '') && $user === $previousUser) { - return; - } - } - - $this->sessionStrategy->onAuthentication($request, $token); - } -} diff --git a/Firewall/X509AuthenticationListener.php b/Firewall/X509AuthenticationListener.php deleted file mode 100644 index 1ae5f667..00000000 --- a/Firewall/X509AuthenticationListener.php +++ /dev/null @@ -1,64 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Firewall; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -trigger_deprecation('symfony/security-http', '5.3', 'The "%s" class is deprecated, use the new authenticator system instead.', X509AuthenticationListener::class); - -/** - * X509 authentication listener. - * - * @author Fabien Potencier - * - * @deprecated since Symfony 5.3, use the new authenticator system instead - */ -class X509AuthenticationListener extends AbstractPreAuthenticatedListener -{ - private $userKey; - private $credentialKey; - - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, string $providerKey, string $userKey = 'SSL_CLIENT_S_DN_Email', string $credentialKey = 'SSL_CLIENT_S_DN', ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null) - { - parent::__construct($tokenStorage, $authenticationManager, $providerKey, $logger, $dispatcher); - - $this->userKey = $userKey; - $this->credentialKey = $credentialKey; - } - - /** - * {@inheritdoc} - */ - protected function getPreAuthenticatedData(Request $request) - { - $user = null; - if ($request->server->has($this->userKey)) { - $user = $request->server->get($this->userKey); - } elseif ( - $request->server->has($this->credentialKey) - && preg_match('#emailAddress=([^,/@]++@[^,/]++)#', $request->server->get($this->credentialKey), $matches) - ) { - $user = $matches[1]; - } - - if (null === $user) { - throw new BadCredentialsException(sprintf('SSL credentials not found: "%s", "%s".', $this->userKey, $this->credentialKey)); - } - - return [$user, $request->server->get($this->credentialKey, '')]; - } -} diff --git a/FirewallMap.php b/FirewallMap.php index a0636cc8..444f71ce 100644 --- a/FirewallMap.php +++ b/FirewallMap.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestMatcherInterface; use Symfony\Component\Security\Http\Firewall\ExceptionListener; +use Symfony\Component\Security\Http\Firewall\FirewallListenerInterface; use Symfony\Component\Security\Http\Firewall\LogoutListener; /** @@ -25,22 +26,19 @@ class FirewallMap implements FirewallMapInterface { /** - * @var list, ExceptionListener|null, LogoutListener|null}> + * @var list, ExceptionListener|null, LogoutListener|null}> */ - private $map = []; + private array $map = []; /** - * @param list $listeners + * @param list $listeners */ - public function add(?RequestMatcherInterface $requestMatcher = null, array $listeners = [], ?ExceptionListener $exceptionListener = null, ?LogoutListener $logoutListener = null) + public function add(?RequestMatcherInterface $requestMatcher = null, array $listeners = [], ?ExceptionListener $exceptionListener = null, ?LogoutListener $logoutListener = null): void { $this->map[] = [$requestMatcher, $listeners, $exceptionListener, $logoutListener]; } - /** - * {@inheritdoc} - */ - public function getListeners(Request $request) + public function getListeners(Request $request): array { foreach ($this->map as $elements) { if (null === $elements[0] || $elements[0]->matches($request)) { diff --git a/FirewallMapInterface.php b/FirewallMapInterface.php index 480ea8ad..1925d3de 100644 --- a/FirewallMapInterface.php +++ b/FirewallMapInterface.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Http\Firewall\ExceptionListener; +use Symfony\Component\Security\Http\Firewall\FirewallListenerInterface; use Symfony\Component\Security\Http\Firewall\LogoutListener; /** @@ -35,7 +36,7 @@ interface FirewallMapInterface * If there is no logout listener, the third element of the outer array * must be null. * - * @return array{iterable, ExceptionListener, LogoutListener} + * @return array{iterable, ExceptionListener, LogoutListener} */ - public function getListeners(Request $request); + public function getListeners(Request $request): array; } diff --git a/HttpUtils.php b/HttpUtils.php index ef91d623..e88f7ee7 100644 --- a/HttpUtils.php +++ b/HttpUtils.php @@ -18,7 +18,6 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Matcher\RequestMatcherInterface; use Symfony\Component\Routing\Matcher\UrlMatcherInterface; -use Symfony\Component\Security\Core\Security; /** * Encapsulates the logic needed to create sub-requests, redirect the user, and match URLs. @@ -27,43 +26,32 @@ */ class HttpUtils { - private $urlGenerator; - private $urlMatcher; - private $domainRegexp; - private $secureDomainRegexp; - /** - * @param UrlMatcherInterface|RequestMatcherInterface $urlMatcher The URL or Request matcher - * @param string|null $domainRegexp A regexp the target of HTTP redirections must match, scheme included - * @param string|null $secureDomainRegexp A regexp the target of HTTP redirections must match when the scheme is "https" + * @param $domainRegexp A regexp the target of HTTP redirections must match, scheme included + * @param $secureDomainRegexp A regexp the target of HTTP redirections must match when the scheme is "https" * * @throws \InvalidArgumentException */ - public function __construct(?UrlGeneratorInterface $urlGenerator = null, $urlMatcher = null, ?string $domainRegexp = null, ?string $secureDomainRegexp = null) - { - $this->urlGenerator = $urlGenerator; - if (null !== $urlMatcher && !$urlMatcher instanceof UrlMatcherInterface && !$urlMatcher instanceof RequestMatcherInterface) { - throw new \InvalidArgumentException('Matcher must either implement UrlMatcherInterface or RequestMatcherInterface.'); - } - $this->urlMatcher = $urlMatcher; - $this->domainRegexp = $domainRegexp; - $this->secureDomainRegexp = $secureDomainRegexp; + public function __construct( + private ?UrlGeneratorInterface $urlGenerator = null, + private UrlMatcherInterface|RequestMatcherInterface|null $urlMatcher = null, + private ?string $domainRegexp = null, + private ?string $secureDomainRegexp = null, + ) { } /** * Creates a redirect Response. * * @param string $path A path (an absolute path (/foo), an absolute URL (http://...), or a route name (foo)) - * @param int $status The status code - * - * @return RedirectResponse + * @param int $status The HTTP status code (302 "Found" by default) */ - public function createRedirectResponse(Request $request, string $path, int $status = 302) + public function createRedirectResponse(Request $request, string $path, int $status = 302): RedirectResponse { - if (null !== $this->secureDomainRegexp && 'https' === $this->urlMatcher->getContext()->getScheme() && preg_match('#^https?:[/\\\\]{2,}+[^/]++#i', $path, $host) && !preg_match(sprintf($this->secureDomainRegexp, preg_quote($request->getHttpHost())), $host[0])) { + if (null !== $this->secureDomainRegexp && 'https' === $this->urlMatcher->getContext()->getScheme() && preg_match('#^https?:[/\\\\]{2,}+[^/]++#i', $path, $host) && !preg_match(\sprintf($this->secureDomainRegexp, preg_quote($request->getHttpHost())), $host[0])) { $path = '/'; } - if (null !== $this->domainRegexp && preg_match('#^https?:[/\\\\]{2,}+[^/]++#i', $path, $host) && !preg_match(sprintf($this->domainRegexp, preg_quote($request->getHttpHost())), $host[0])) { + if (null !== $this->domainRegexp && preg_match('#^https?:[/\\\\]{2,}+[^/]++#i', $path, $host) && !preg_match(\sprintf($this->domainRegexp, preg_quote($request->getHttpHost())), $host[0])) { $path = '/'; } @@ -74,28 +62,24 @@ public function createRedirectResponse(Request $request, string $path, int $stat * Creates a Request. * * @param string $path A path (an absolute path (/foo), an absolute URL (http://...), or a route name (foo)) - * - * @return Request */ - public function createRequest(Request $request, string $path) + public function createRequest(Request $request, string $path): Request { $newRequest = Request::create($this->generateUri($request, $path), 'get', [], $request->cookies->all(), [], $request->server->all()); static $setSession; - if (null === $setSession) { - $setSession = \Closure::bind(static function ($newRequest, $request) { $newRequest->session = $request->session; }, null, Request::class); - } + $setSession ??= \Closure::bind(static function ($newRequest, $request) { $newRequest->session = $request->session; }, null, Request::class); $setSession($newRequest, $request); - if ($request->attributes->has(Security::AUTHENTICATION_ERROR)) { - $newRequest->attributes->set(Security::AUTHENTICATION_ERROR, $request->attributes->get(Security::AUTHENTICATION_ERROR)); + if ($request->attributes->has(SecurityRequestAttributes::AUTHENTICATION_ERROR)) { + $newRequest->attributes->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $request->attributes->get(SecurityRequestAttributes::AUTHENTICATION_ERROR)); } - if ($request->attributes->has(Security::ACCESS_DENIED_ERROR)) { - $newRequest->attributes->set(Security::ACCESS_DENIED_ERROR, $request->attributes->get(Security::ACCESS_DENIED_ERROR)); + if ($request->attributes->has(SecurityRequestAttributes::ACCESS_DENIED_ERROR)) { + $newRequest->attributes->set(SecurityRequestAttributes::ACCESS_DENIED_ERROR, $request->attributes->get(SecurityRequestAttributes::ACCESS_DENIED_ERROR)); } - if ($request->attributes->has(Security::LAST_USERNAME)) { - $newRequest->attributes->set(Security::LAST_USERNAME, $request->attributes->get(Security::LAST_USERNAME)); + if ($request->attributes->has(SecurityRequestAttributes::LAST_USERNAME)) { + $newRequest->attributes->set(SecurityRequestAttributes::LAST_USERNAME, $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME)); } if ($request->get('_format')) { @@ -115,7 +99,7 @@ public function createRequest(Request $request, string $path) * * @return bool true if the path is the same as the one from the Request, false otherwise */ - public function checkRequestPath(Request $request, string $path) + public function checkRequestPath(Request $request, string $path): bool { if ('/' !== $path[0]) { // Shortcut if request has already been matched before @@ -132,9 +116,7 @@ public function checkRequestPath(Request $request, string $path) } return isset($parameters['_route']) && $path === $parameters['_route']; - } catch (MethodNotAllowedException $e) { - return false; - } catch (ResourceNotFoundException $e) { + } catch (MethodNotAllowedException|ResourceNotFoundException) { return false; } } @@ -147,11 +129,9 @@ public function checkRequestPath(Request $request, string $path) * * @param string $path A path (an absolute path (/foo), an absolute URL (http://...), or a route name (foo)) * - * @return string - * * @throws \LogicException */ - public function generateUri(Request $request, string $path) + public function generateUri(Request $request, string $path): string { $url = parse_url($path); diff --git a/Impersonate/ImpersonateUrlGenerator.php b/Impersonate/ImpersonateUrlGenerator.php index cccc3784..07bdffbc 100644 --- a/Impersonate/ImpersonateUrlGenerator.php +++ b/Impersonate/ImpersonateUrlGenerator.php @@ -18,27 +18,37 @@ use Symfony\Component\Security\Http\Firewall\SwitchUserListener; /** - * Provides generator functions for the impersonate url exit. + * Provides generator functions for the impersonation urls. * * @author Amrouche Hamza * @author Damien Fayet */ class ImpersonateUrlGenerator { - private $requestStack; - private $tokenStorage; - private $firewallMap; + public function __construct( + private RequestStack $requestStack, + private FirewallMap $firewallMap, + private TokenStorageInterface $tokenStorage, + ) { + } + + public function generateImpersonationPath(string $identifier): string + { + return $this->buildPath(null, $identifier); + } - public function __construct(RequestStack $requestStack, FirewallMap $firewallMap, TokenStorageInterface $tokenStorage) + public function generateImpersonationUrl(string $identifier): string { - $this->requestStack = $requestStack; - $this->tokenStorage = $tokenStorage; - $this->firewallMap = $firewallMap; + if (null === $request = $this->requestStack->getCurrentRequest()) { + return ''; + } + + return $request->getUriForPath($this->buildPath(null, $identifier)); } public function generateExitPath(?string $targetUri = null): string { - return $this->buildExitPath($targetUri); + return $this->buildPath($targetUri); } public function generateExitUrl(?string $targetUri = null): string @@ -47,7 +57,7 @@ public function generateExitUrl(?string $targetUri = null): string return ''; } - return $request->getUriForPath($this->buildExitPath($targetUri)); + return $request->getUriForPath($this->buildPath($targetUri)); } private function isImpersonatedUser(): bool @@ -55,21 +65,23 @@ private function isImpersonatedUser(): bool return $this->tokenStorage->getToken() instanceof SwitchUserToken; } - private function buildExitPath(?string $targetUri = null): string + private function buildPath(?string $targetUri = null, string $identifier = SwitchUserListener::EXIT_VALUE): string { - if (null === ($request = $this->requestStack->getCurrentRequest()) || !$this->isImpersonatedUser()) { + if (null === ($request = $this->requestStack->getCurrentRequest())) { return ''; } - if (null === $switchUserConfig = $this->firewallMap->getFirewallConfig($request)->getSwitchUser()) { - throw new \LogicException('Unable to generate the impersonate exit URL without a firewall configured for the user switch.'); + if (!$this->isImpersonatedUser() && SwitchUserListener::EXIT_VALUE == $identifier) { + return ''; } - if (null === $targetUri) { - $targetUri = $request->getRequestUri(); + if (null === $switchUserConfig = $this->firewallMap->getFirewallConfig($request)->getSwitchUser()) { + throw new \LogicException('Unable to generate the impersonate URLs without a firewall configured for the user switch.'); } - $targetUri .= (str_contains($targetUri, '?') ? '&' : '?').http_build_query([$switchUserConfig['parameter'] => SwitchUserListener::EXIT_VALUE], '', '&'); + $targetUri ??= $request->getRequestUri(); + + $targetUri .= (str_contains($targetUri, '?') ? '&' : '?').http_build_query([$switchUserConfig['parameter'] => $identifier], '', '&'); return $targetUri; } diff --git a/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php b/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php index a4f64bab..f2debd98 100644 --- a/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php +++ b/LoginLink/Exception/InvalidLoginLinkAuthenticationException.php @@ -20,10 +20,7 @@ */ class InvalidLoginLinkAuthenticationException extends AuthenticationException { - /** - * {@inheritdoc} - */ - public function getMessageKey() + public function getMessageKey(): string { return 'Invalid or expired login link.'; } diff --git a/LoginLink/LoginLinkDetails.php b/LoginLink/LoginLinkDetails.php index 9057a095..6d71bdfd 100644 --- a/LoginLink/LoginLinkDetails.php +++ b/LoginLink/LoginLinkDetails.php @@ -16,13 +16,10 @@ */ class LoginLinkDetails { - private $url; - private $expiresAt; - - public function __construct(string $url, \DateTimeImmutable $expiresAt) - { - $this->url = $url; - $this->expiresAt = $expiresAt; + public function __construct( + private string $url, + private \DateTimeImmutable $expiresAt, + ) { } public function getUrl(): string @@ -35,7 +32,7 @@ public function getExpiresAt(): \DateTimeImmutable return $this->expiresAt; } - public function __toString() + public function __toString(): string { return $this->url; } diff --git a/LoginLink/LoginLinkHandler.php b/LoginLink/LoginLinkHandler.php index 00ba82d0..61f43018 100644 --- a/LoginLink/LoginLinkHandler.php +++ b/LoginLink/LoginLinkHandler.php @@ -28,30 +28,27 @@ */ final class LoginLinkHandler implements LoginLinkHandlerInterface { - private $urlGenerator; - private $userProvider; - private $options; - private $signatureHasher; - - public function __construct(UrlGeneratorInterface $urlGenerator, UserProviderInterface $userProvider, SignatureHasher $signatureHasher, array $options) - { - $this->urlGenerator = $urlGenerator; - $this->userProvider = $userProvider; - $this->signatureHasher = $signatureHasher; + private array $options; + + public function __construct( + private UrlGeneratorInterface $urlGenerator, + private UserProviderInterface $userProvider, + private SignatureHasher $signatureHasher, + array $options, + ) { $this->options = array_merge([ 'route_name' => null, 'lifetime' => 600, ], $options); } - public function createLoginLink(UserInterface $user, ?Request $request = null): LoginLinkDetails + public function createLoginLink(UserInterface $user, ?Request $request = null, ?int $lifetime = null): LoginLinkDetails { - $expires = time() + $this->options['lifetime']; + $expires = time() + ($lifetime ?: $this->options['lifetime']); $expiresAt = new \DateTimeImmutable('@'.$expires); $parameters = [ - // @deprecated since Symfony 5.3, change to $user->getUserIdentifier() in 6.0 - 'user' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), + 'user' => $user->getUserIdentifier(), 'expires' => $expires, 'hash' => $this->signatureHasher->computeSignatureHash($user, $expires), ]; @@ -87,21 +84,21 @@ public function consumeLoginLink(Request $request): UserInterface if (!$hash = $request->get('hash')) { throw new InvalidLoginLinkException('Missing "hash" parameter.'); } + if (!\is_string($hash)) { + throw new InvalidLoginLinkException('Invalid "hash" parameter.'); + } + if (!$expires = $request->get('expires')) { throw new InvalidLoginLinkException('Missing "expires" parameter.'); } + if (!preg_match('/^\d+$/', $expires)) { + throw new InvalidLoginLinkException('Invalid "expires" parameter.'); + } try { $this->signatureHasher->acceptSignatureHash($userIdentifier, $expires, $hash); - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - if (method_exists($this->userProvider, 'loadUserByIdentifier')) { - $user = $this->userProvider->loadUserByIdentifier($userIdentifier); - } else { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); - - $user = $this->userProvider->loadUserByUsername($userIdentifier); - } + $user = $this->userProvider->loadUserByIdentifier($userIdentifier); $this->signatureHasher->verifySignatureHash($user, $expires, $hash); } catch (UserNotFoundException $e) { diff --git a/LoginLink/LoginLinkHandlerInterface.php b/LoginLink/LoginLinkHandlerInterface.php index 9fbe5c72..8a682e5a 100644 --- a/LoginLink/LoginLinkHandlerInterface.php +++ b/LoginLink/LoginLinkHandlerInterface.php @@ -23,8 +23,10 @@ interface LoginLinkHandlerInterface { /** * Generate a link that can be used to authenticate as the given user. + * + * @param int|null $lifetime When not null, the argument overrides any default lifetime previously set */ - public function createLoginLink(UserInterface $user, ?Request $request = null): LoginLinkDetails; + public function createLoginLink(UserInterface $user, ?Request $request = null, ?int $lifetime = null): LoginLinkDetails; /** * Validates if this request contains a login link and returns the associated User. diff --git a/LoginLink/LoginLinkNotification.php b/LoginLink/LoginLinkNotification.php index 1fbe75c9..3b9bd351 100644 --- a/LoginLink/LoginLinkNotification.php +++ b/LoginLink/LoginLinkNotification.php @@ -28,19 +28,18 @@ */ class LoginLinkNotification extends Notification implements EmailNotificationInterface, SmsNotificationInterface { - private $loginLinkDetails; - - public function __construct(LoginLinkDetails $loginLinkDetails, string $subject, array $channels = []) - { + public function __construct( + private LoginLinkDetails $loginLinkDetails, + string $subject, + array $channels = [], + ) { parent::__construct($subject, $channels); - - $this->loginLinkDetails = $loginLinkDetails; } public function asEmailMessage(EmailRecipientInterface $recipient, ?string $transport = null): ?EmailMessage { if (!class_exists(NotificationEmail::class)) { - throw new \LogicException(sprintf('The "%s" method requires "symfony/twig-bridge:>4.4".', __METHOD__)); + throw new \LogicException(\sprintf('The "%s" method requires "symfony/twig-bridge:>4.4".', __METHOD__)); } $email = NotificationEmail::asPublicEmail() @@ -66,6 +65,6 @@ private function getDefaultContent(string $target): string $durationString = floor($hours).' hour'.($hours >= 2 ? 's' : ''); } - return sprintf('Click on the %s to confirm you want to sign in. This link will expire in %s.', $target, $durationString); + return \sprintf('Click on the %s to confirm you want to sign in. This link will expire in %s.', $target, $durationString); } } diff --git a/Logout/CookieClearingLogoutHandler.php b/Logout/CookieClearingLogoutHandler.php deleted file mode 100644 index 2adb5b3f..00000000 --- a/Logout/CookieClearingLogoutHandler.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Logout; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Http\EventListener\CookieClearingLogoutListener; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" instead.', CookieClearingLogoutHandler::class, CookieClearingLogoutListener::class); - -/** - * This handler clears the passed cookies when a user logs out. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.4, use {@link CookieClearingLogoutListener} instead - */ -class CookieClearingLogoutHandler implements LogoutHandlerInterface -{ - private $cookies; - - /** - * @param array $cookies An array of cookie names to unset - */ - public function __construct(array $cookies) - { - $this->cookies = $cookies; - } - - /** - * Implementation for the LogoutHandlerInterface. Deletes all requested cookies. - */ - public function logout(Request $request, Response $response, TokenInterface $token) - { - foreach ($this->cookies as $cookieName => $cookieData) { - $response->headers->clearCookie($cookieName, $cookieData['path'], $cookieData['domain'], $cookieData['secure'] ?? false, true, $cookieData['samesite'] ?? null); - } - } -} diff --git a/Logout/CsrfTokenClearingLogoutHandler.php b/Logout/CsrfTokenClearingLogoutHandler.php deleted file mode 100644 index 2678da73..00000000 --- a/Logout/CsrfTokenClearingLogoutHandler.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Logout; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Csrf\TokenStorage\ClearableTokenStorageInterface; -use Symfony\Component\Security\Http\EventListener\CsrfTokenClearingLogoutListener; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" instead.', CsrfTokenClearingLogoutHandler::class, CsrfTokenClearingLogoutListener::class); - -/** - * @author Christian Flothmann - * - * @deprecated since Symfony 5.4, use {@link CsrfTokenClearingLogoutListener} instead - */ -class CsrfTokenClearingLogoutHandler implements LogoutHandlerInterface -{ - private $csrfTokenStorage; - - public function __construct(ClearableTokenStorageInterface $csrfTokenStorage) - { - $this->csrfTokenStorage = $csrfTokenStorage; - } - - public function logout(Request $request, Response $response, TokenInterface $token) - { - $this->csrfTokenStorage->clear(); - } -} diff --git a/Logout/DefaultLogoutSuccessHandler.php b/Logout/DefaultLogoutSuccessHandler.php deleted file mode 100644 index dbf30ce8..00000000 --- a/Logout/DefaultLogoutSuccessHandler.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Logout; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Http\EventListener\DefaultLogoutListener; -use Symfony\Component\Security\Http\HttpUtils; - -trigger_deprecation('symfony/security-http', '5.1', 'The "%s" class is deprecated, use "%s" instead.', DefaultLogoutSuccessHandler::class, DefaultLogoutListener::class); - -/** - * Default logout success handler will redirect users to a configured path. - * - * @author Fabien Potencier - * @author Alexander - * - * @deprecated since Symfony 5.1 - */ -class DefaultLogoutSuccessHandler implements LogoutSuccessHandlerInterface -{ - protected $httpUtils; - protected $targetUrl; - - public function __construct(HttpUtils $httpUtils, string $targetUrl = '/') - { - $this->httpUtils = $httpUtils; - $this->targetUrl = $targetUrl; - } - - /** - * {@inheritdoc} - */ - public function onLogoutSuccess(Request $request) - { - return $this->httpUtils->createRedirectResponse($request, $this->targetUrl); - } -} diff --git a/Logout/LogoutHandlerInterface.php b/Logout/LogoutHandlerInterface.php deleted file mode 100644 index 4c19b459..00000000 --- a/Logout/LogoutHandlerInterface.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Logout; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; - -/** - * Interface that needs to be implemented by LogoutHandlers. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.1 - */ -interface LogoutHandlerInterface -{ - /** - * This method is called by the LogoutListener when a user has requested - * to be logged out. Usually, you would unset session variables, or remove - * cookies, etc. - */ - public function logout(Request $request, Response $response, TokenInterface $token); -} diff --git a/Logout/LogoutSuccessHandlerInterface.php b/Logout/LogoutSuccessHandlerInterface.php deleted file mode 100644 index 90d96050..00000000 --- a/Logout/LogoutSuccessHandlerInterface.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Logout; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Http\Event\LogoutEvent; - -trigger_deprecation('symfony/security-http', '5.1', 'The "%s" interface is deprecated, create a listener for the "%s" event instead.', LogoutSuccessHandlerInterface::class, LogoutEvent::class); - -/** - * LogoutSuccesshandlerInterface. - * - * In contrast to the LogoutHandlerInterface, this interface can return a response - * which is then used instead of the default behavior. - * - * If you want to only perform some logout related clean-up task, use the - * LogoutHandlerInterface instead. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.1 - */ -interface LogoutSuccessHandlerInterface -{ - /** - * Creates a Response object to send upon a successful logout. - * - * @return Response - */ - public function onLogoutSuccess(Request $request); -} diff --git a/Logout/LogoutUrlGenerator.php b/Logout/LogoutUrlGenerator.php index bded7475..aa874bbd 100644 --- a/Logout/LogoutUrlGenerator.php +++ b/Logout/LogoutUrlGenerator.php @@ -13,7 +13,6 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -25,20 +24,15 @@ */ class LogoutUrlGenerator { - private $requestStack; - private $router; - private $tokenStorage; - private $listeners = []; - /** @var string|null */ - private $currentFirewallName; - /** @var string|null */ - private $currentFirewallContext; - - public function __construct(?RequestStack $requestStack = null, ?UrlGeneratorInterface $router = null, ?TokenStorageInterface $tokenStorage = null) - { - $this->requestStack = $requestStack; - $this->router = $router; - $this->tokenStorage = $tokenStorage; + private array $listeners = []; + private ?string $currentFirewallName = null; + private ?string $currentFirewallContext = null; + + public function __construct( + private ?RequestStack $requestStack = null, + private ?UrlGeneratorInterface $router = null, + private ?TokenStorageInterface $tokenStorage = null, + ) { } /** @@ -50,32 +44,28 @@ public function __construct(?RequestStack $requestStack = null, ?UrlGeneratorInt * @param string|null $csrfParameter The CSRF token parameter name * @param string|null $context The listener context */ - public function registerListener(string $key, string $logoutPath, ?string $csrfTokenId, ?string $csrfParameter, ?CsrfTokenManagerInterface $csrfTokenManager = null, ?string $context = null) + public function registerListener(string $key, string $logoutPath, ?string $csrfTokenId, ?string $csrfParameter, ?CsrfTokenManagerInterface $csrfTokenManager = null, ?string $context = null): void { $this->listeners[$key] = [$logoutPath, $csrfTokenId, $csrfParameter, $csrfTokenManager, $context]; } /** * Generates the absolute logout path for the firewall. - * - * @return string */ - public function getLogoutPath(?string $key = null) + public function getLogoutPath(?string $key = null): string { return $this->generateLogoutUrl($key, UrlGeneratorInterface::ABSOLUTE_PATH); } /** * Generates the absolute logout URL for the firewall. - * - * @return string */ - public function getLogoutUrl(?string $key = null) + public function getLogoutUrl(?string $key = null): string { return $this->generateLogoutUrl($key, UrlGeneratorInterface::ABSOLUTE_URL); } - public function setCurrentFirewall(?string $key, ?string $context = null) + public function setCurrentFirewall(?string $key, ?string $context = null): void { $this->currentFirewallName = $key; $this->currentFirewallContext = $context; @@ -107,7 +97,7 @@ private function generateLogoutUrl(?string $key, int $referenceType): string $url = UrlGeneratorInterface::ABSOLUTE_URL === $referenceType ? $request->getUriForPath($logoutPath) : $request->getBaseUrl().$logoutPath; - if (!empty($parameters)) { + if ($parameters) { $url .= '?'.http_build_query($parameters, '', '&'); } } else { @@ -131,26 +121,15 @@ private function getListener(?string $key): array return $this->listeners[$key]; } - throw new \InvalidArgumentException(sprintf('No LogoutListener found for firewall key "%s".', $key)); + throw new \InvalidArgumentException(\sprintf('No LogoutListener found for firewall key "%s".', $key)); } // Fetch the current provider key from token, if possible if (null !== $this->tokenStorage) { $token = $this->tokenStorage->getToken(); - // @deprecated since Symfony 5.4 - if ($token instanceof AnonymousToken) { - throw new \InvalidArgumentException('Unable to generate a logout url for an anonymous token.'); - } - - if (null !== $token) { - if (method_exists($token, 'getFirewallName')) { - $key = $token->getFirewallName(); - } elseif (method_exists($token, 'getProviderKey')) { - trigger_deprecation('symfony/security-http', '5.2', 'Method "%s::getProviderKey()" has been deprecated, rename it to "getFirewallName()" instead.', \get_class($token)); - - $key = $token->getProviderKey(); - } + if (null !== $token && method_exists($token, 'getFirewallName')) { + $key = $token->getFirewallName(); if (isset($this->listeners[$key])) { return $this->listeners[$key]; @@ -169,6 +148,10 @@ private function getListener(?string $key): array } } - throw new \InvalidArgumentException('Unable to find the current firewall LogoutListener, please provide the provider key manually.'); + if (null === $this->currentFirewallName) { + throw new \InvalidArgumentException('This request is not behind a firewall, pass the firewall name manually to generate a logout URL.'); + } + + throw new \InvalidArgumentException('Unable to find logout in the current firewall, pass the firewall name manually to generate a logout URL.'); } } diff --git a/Logout/SessionLogoutHandler.php b/Logout/SessionLogoutHandler.php deleted file mode 100644 index 09e4ea00..00000000 --- a/Logout/SessionLogoutHandler.php +++ /dev/null @@ -1,37 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Logout; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Http\EventListener\SessionLogoutListener; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" instead.', SessionLogoutHandler::class, SessionLogoutListener::class); - -/** - * Handler for clearing invalidating the current session. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.4, use {@link SessionLogoutListener} instead - */ -class SessionLogoutHandler implements LogoutHandlerInterface -{ - /** - * Invalidate the current session. - */ - public function logout(Request $request, Response $response, TokenInterface $token) - { - $request->getSession()->invalidate(); - } -} diff --git a/ParameterBagUtils.php b/ParameterBagUtils.php index db7ac6e1..429103e0 100644 --- a/ParameterBagUtils.php +++ b/ParameterBagUtils.php @@ -16,24 +16,23 @@ use Symfony\Component\PropertyAccess\Exception\AccessException; use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; /** * @internal */ final class ParameterBagUtils { - private static $propertyAccessor; + private static PropertyAccessorInterface $propertyAccessor; /** * Returns a "parameter" value. * * Paths like foo[bar] will be evaluated to find deeper items in nested data structures. * - * @return mixed - * * @throws InvalidArgumentException when the given path is malformed */ - public static function getParameterBagValue(ParameterBag $parameters, string $path) + public static function getParameterBagValue(ParameterBag $parameters, string $path): mixed { if (false === $pos = strpos($path, '[')) { return $parameters->all()[$path] ?? null; @@ -45,13 +44,11 @@ public static function getParameterBagValue(ParameterBag $parameters, string $pa return null; } - if (null === self::$propertyAccessor) { - self::$propertyAccessor = PropertyAccess::createPropertyAccessor(); - } + self::$propertyAccessor ??= PropertyAccess::createPropertyAccessor(); try { return self::$propertyAccessor->getValue($value, substr($path, $pos)); - } catch (AccessException $e) { + } catch (AccessException) { return null; } } @@ -61,29 +58,31 @@ public static function getParameterBagValue(ParameterBag $parameters, string $pa * * Paths like foo[bar] will be evaluated to find deeper items in nested data structures. * - * @return mixed - * * @throws InvalidArgumentException when the given path is malformed */ - public static function getRequestParameterValue(Request $request, string $path) + public static function getRequestParameterValue(Request $request, string $path, array $parameters = []): mixed { if (false === $pos = strpos($path, '[')) { - return $request->get($path); + return $parameters[$path] ?? $request->get($path); } $root = substr($path, 0, $pos); - if (null === $value = $request->get($root)) { + if (null === $value = $parameters[$root] ?? $request->get($root)) { return null; } - if (null === self::$propertyAccessor) { - self::$propertyAccessor = PropertyAccess::createPropertyAccessor(); - } + self::$propertyAccessor ??= PropertyAccess::createPropertyAccessor(); try { - return self::$propertyAccessor->getValue($value, substr($path, $pos)); - } catch (AccessException $e) { + $value = self::$propertyAccessor->getValue($value, substr($path, $pos)); + + if (null === $value && isset($parameters[$root]) && null !== $value = $request->get($root)) { + $value = self::$propertyAccessor->getValue($value, substr($path, $pos)); + } + + return $value; + } catch (AccessException) { return null; } } diff --git a/README.md b/README.md index 91a75833..93fce307 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ provides authenticators to authenticate visitors. Getting Started --------------- -``` -$ composer require symfony/security-http +```bash +composer require symfony/security-http ``` Sponsor ------- -The Security component for Symfony 5.4/6.0 is [backed][1] by [SymfonyCasts][2]. +The Security component for Symfony 7.1 is [backed][1] by [SymfonyCasts][2]. Learn Symfony faster by watching real projects being built and actively coding along with them. SymfonyCasts bridges that learning gap, bringing you video diff --git a/RateLimiter/DefaultLoginRateLimiter.php b/RateLimiter/DefaultLoginRateLimiter.php index 7b773f24..98e41ffc 100644 --- a/RateLimiter/DefaultLoginRateLimiter.php +++ b/RateLimiter/DefaultLoginRateLimiter.php @@ -14,7 +14,8 @@ use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\RateLimiter\RateLimiterFactory; -use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; +use Symfony\Component\Security\Http\SecurityRequestAttributes; /** * A default login throttling limiter. @@ -26,23 +27,32 @@ */ final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter { - private $globalFactory; - private $localFactory; - - public function __construct(RateLimiterFactory $globalFactory, RateLimiterFactory $localFactory) - { - $this->globalFactory = $globalFactory; - $this->localFactory = $localFactory; + /** + * @param non-empty-string $secret A secret to use for hashing the IP address and username + */ + public function __construct( + private RateLimiterFactory $globalFactory, + private RateLimiterFactory $localFactory, + #[\SensitiveParameter] private string $secret, + ) { + if (!$secret) { + throw new InvalidArgumentException('A non-empty secret is required.'); + } } protected function getLimiters(Request $request): array { - $username = $request->attributes->get(Security::LAST_USERNAME, ''); + $username = $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME, ''); $username = preg_match('//u', $username) ? mb_strtolower($username, 'UTF-8') : strtolower($username); return [ - $this->globalFactory->create($request->getClientIp()), - $this->localFactory->create($username.'-'.$request->getClientIp()), + $this->globalFactory->create($this->hash($request->getClientIp())), + $this->localFactory->create($this->hash($username.'-'.$request->getClientIp())), ]; } + + private function hash(string $data): string + { + return strtr(substr(base64_encode(hash_hmac('sha256', $data, $this->secret, true)), 0, 8), '/+', '._'); + } } diff --git a/RememberMe/AbstractRememberMeHandler.php b/RememberMe/AbstractRememberMeHandler.php index c76049fb..6a1df337 100644 --- a/RememberMe/AbstractRememberMeHandler.php +++ b/RememberMe/AbstractRememberMeHandler.php @@ -23,15 +23,14 @@ */ abstract class AbstractRememberMeHandler implements RememberMeHandlerInterface { - private $userProvider; - protected $requestStack; - protected $options; - protected $logger; - - public function __construct(UserProviderInterface $userProvider, RequestStack $requestStack, array $options = [], ?LoggerInterface $logger = null) - { - $this->userProvider = $userProvider; - $this->requestStack = $requestStack; + protected array $options; + + public function __construct( + private UserProviderInterface $userProvider, + protected RequestStack $requestStack, + array $options = [], + protected ?LoggerInterface $logger = null, + ) { $this->options = $options + [ 'name' => 'REMEMBERME', 'lifetime' => 31536000, @@ -43,7 +42,6 @@ public function __construct(UserProviderInterface $userProvider, RequestStack $r 'always_remember_me' => false, 'remember_me_parameter' => '_remember_me', ]; - $this->logger = $logger; } /** @@ -57,46 +55,19 @@ public function __construct(UserProviderInterface $userProvider, RequestStack $r */ abstract protected function processRememberMe(RememberMeDetails $rememberMeDetails, UserInterface $user): void; - /** - * {@inheritdoc} - */ public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): UserInterface { - try { - // @deprecated since Symfony 5.3, change to $this->userProvider->loadUserByIdentifier() in 6.0 - $method = 'loadUserByIdentifier'; - if (!method_exists($this->userProvider, 'loadUserByIdentifier')) { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($this->userProvider)); - - $method = 'loadUserByUsername'; - } - - $user = $this->userProvider->$method($rememberMeDetails->getUserIdentifier()); - } catch (AuthenticationException $e) { - throw $e; - } - - if (!$user instanceof UserInterface) { - throw new \LogicException(sprintf('The UserProviderInterface implementation must return an instance of UserInterface, but returned "%s".', get_debug_type($user))); - } - + $user = $this->userProvider->loadUserByIdentifier($rememberMeDetails->getUserIdentifier()); $this->processRememberMe($rememberMeDetails, $user); - if (null !== $this->logger) { - $this->logger->info('Remember-me cookie accepted.'); - } + $this->logger?->info('Remember-me cookie accepted.'); return $user; } - /** - * {@inheritdoc} - */ public function clearRememberMeCookie(): void { - if (null !== $this->logger) { - $this->logger->debug('Clearing remember-me cookie.', ['name' => $this->options['name']]); - } + $this->logger?->debug('Clearing remember-me cookie.', ['name' => $this->options['name']]); $this->createCookie(null); } @@ -106,7 +77,7 @@ public function clearRememberMeCookie(): void * * @param RememberMeDetails|null $rememberMeDetails The details for the cookie, or null to clear the remember-me cookie */ - protected function createCookie(?RememberMeDetails $rememberMeDetails) + protected function createCookie(?RememberMeDetails $rememberMeDetails): void { $request = $this->requestStack->getMainRequest(); if (!$request) { @@ -116,8 +87,8 @@ protected function createCookie(?RememberMeDetails $rememberMeDetails) // the ResponseListener configures the cookie saved in this attribute on the final response object $request->attributes->set(ResponseListener::COOKIE_ATTR_NAME, new Cookie( $this->options['name'], - $rememberMeDetails ? $rememberMeDetails->toString() : null, - $rememberMeDetails ? $rememberMeDetails->getExpires() : 1, + $rememberMeDetails?->toString(), + $rememberMeDetails?->getExpires() ?? 1, $this->options['path'], $this->options['domain'], $this->options['secure'] ?? $request->isSecure(), diff --git a/RememberMe/AbstractRememberMeServices.php b/RememberMe/AbstractRememberMeServices.php deleted file mode 100644 index 84a7950c..00000000 --- a/RememberMe/AbstractRememberMeServices.php +++ /dev/null @@ -1,309 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\RememberMe; - -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\CookieTheftException; -use Symfony\Component\Security\Core\Exception\UnsupportedUserException; -use Symfony\Component\Security\Core\Exception\UserNotFoundException; -use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Core\User\UserProviderInterface; -use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface; -use Symfony\Component\Security\Http\ParameterBagUtils; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" instead.', AbstractRememberMeServices::class, AbstractRememberMeHandler::class); - -/** - * Base class implementing the RememberMeServicesInterface. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.4, use {@see AbstractRememberMeHandler} instead - */ -abstract class AbstractRememberMeServices implements RememberMeServicesInterface, LogoutHandlerInterface -{ - public const COOKIE_DELIMITER = ':'; - - protected $logger; - protected $options = [ - 'secure' => false, - 'httponly' => true, - 'samesite' => null, - 'path' => null, - 'domain' => null, - ]; - private $firewallName; - private $secret; - private $userProviders; - - /** - * @throws \InvalidArgumentException - */ - public function __construct(iterable $userProviders, string $secret, string $firewallName, array $options = [], ?LoggerInterface $logger = null) - { - if (empty($secret)) { - throw new \InvalidArgumentException('$secret must not be empty.'); - } - if ('' === $firewallName) { - throw new \InvalidArgumentException('$firewallName must not be empty.'); - } - if (!\is_array($userProviders) && !$userProviders instanceof \Countable) { - $userProviders = iterator_to_array($userProviders, false); - } - if (0 === \count($userProviders)) { - throw new \InvalidArgumentException('You must provide at least one user provider.'); - } - - $this->userProviders = $userProviders; - $this->secret = $secret; - $this->firewallName = $firewallName; - $this->options = array_merge($this->options, $options); - $this->logger = $logger; - } - - /** - * Returns the parameter that is used for checking whether remember-me - * services have been requested. - * - * @return string - */ - public function getRememberMeParameter() - { - return $this->options['remember_me_parameter']; - } - - /** - * @return string - */ - public function getSecret() - { - return $this->secret; - } - - /** - * Implementation of RememberMeServicesInterface. Detects whether a remember-me - * cookie was set, decodes it, and hands it to subclasses for further processing. - * - * @throws CookieTheftException - * @throws \RuntimeException - */ - final public function autoLogin(Request $request): ?TokenInterface - { - if (($cookie = $request->attributes->get(self::COOKIE_ATTR_NAME)) && null === $cookie->getValue()) { - return null; - } - - if (null === $cookie = $request->cookies->get($this->options['name'])) { - return null; - } - - if (null !== $this->logger) { - $this->logger->debug('Remember-me cookie detected.'); - } - - $cookieParts = $this->decodeCookie($cookie); - - try { - $user = $this->processAutoLoginCookie($cookieParts, $request); - - if (!$user instanceof UserInterface) { - throw new \RuntimeException('processAutoLoginCookie() must return a UserInterface implementation.'); - } - - if (null !== $this->logger) { - $this->logger->info('Remember-me cookie accepted.'); - } - - return new RememberMeToken($user, $this->firewallName, $this->secret); - } catch (CookieTheftException $e) { - $this->loginFail($request, $e); - - throw $e; - } catch (UserNotFoundException $e) { - if (null !== $this->logger) { - $this->logger->info('User for remember-me cookie not found.', ['exception' => $e]); - } - - $this->loginFail($request, $e); - } catch (UnsupportedUserException $e) { - if (null !== $this->logger) { - $this->logger->warning('User class for remember-me cookie not supported.', ['exception' => $e]); - } - - $this->loginFail($request, $e); - } catch (AuthenticationException $e) { - if (null !== $this->logger) { - $this->logger->debug('Remember-Me authentication failed.', ['exception' => $e]); - } - - $this->loginFail($request, $e); - } catch (\Exception $e) { - $this->loginFail($request, $e); - - throw $e; - } - - return null; - } - - /** - * Implementation for LogoutHandlerInterface. Deletes the cookie. - */ - public function logout(Request $request, Response $response, TokenInterface $token) - { - $this->cancelCookie($request); - } - - /** - * Implementation for RememberMeServicesInterface. Deletes the cookie when - * an attempted authentication fails. - */ - final public function loginFail(Request $request, ?\Exception $exception = null) - { - $this->cancelCookie($request); - $this->onLoginFail($request, $exception); - } - - /** - * Implementation for RememberMeServicesInterface. This is called when an - * authentication is successful. - */ - final public function loginSuccess(Request $request, Response $response, TokenInterface $token) - { - // Make sure any old remember-me cookies are cancelled - $this->cancelCookie($request); - - if (!$token->getUser() instanceof UserInterface) { - if (null !== $this->logger) { - $this->logger->debug('Remember-me ignores token since it does not contain a UserInterface implementation.'); - } - - return; - } - - if (!$this->isRememberMeRequested($request)) { - if (null !== $this->logger) { - $this->logger->debug('Remember-me was not requested.'); - } - - return; - } - - if (null !== $this->logger) { - $this->logger->debug('Remember-me was requested; setting cookie.'); - } - - // Remove attribute from request that sets a NULL cookie. - // It was set by $this->cancelCookie() - // (cancelCookie does other things too for some RememberMeServices - // so we should still call it at the start of this method) - $request->attributes->remove(self::COOKIE_ATTR_NAME); - - $this->onLoginSuccess($request, $response, $token); - } - - /** - * Subclasses should validate the cookie and do any additional processing - * that is required. This is called from autoLogin(). - * - * @return UserInterface - */ - abstract protected function processAutoLoginCookie(array $cookieParts, Request $request); - - protected function onLoginFail(Request $request, ?\Exception $exception = null) - { - } - - /** - * This is called after a user has been logged in successfully, and has - * requested remember-me capabilities. The implementation usually sets a - * cookie and possibly stores a persistent record of it. - */ - abstract protected function onLoginSuccess(Request $request, Response $response, TokenInterface $token); - - final protected function getUserProvider(string $class): UserProviderInterface - { - foreach ($this->userProviders as $provider) { - if ($provider->supportsClass($class)) { - return $provider; - } - } - - throw new UnsupportedUserException(sprintf('There is no user provider for user "%s". Shouldn\'t the "supportsClass()" method of your user provider return true for this classname?', $class)); - } - - /** - * Decodes the raw cookie value. - * - * @return array - */ - protected function decodeCookie(string $rawCookie) - { - return explode(self::COOKIE_DELIMITER, base64_decode($rawCookie)); - } - - /** - * Encodes the cookie parts. - * - * @return string - * - * @throws \InvalidArgumentException When $cookieParts contain the cookie delimiter. Extending class should either remove or escape it. - */ - protected function encodeCookie(array $cookieParts) - { - foreach ($cookieParts as $cookiePart) { - if (str_contains($cookiePart, self::COOKIE_DELIMITER)) { - throw new \InvalidArgumentException(sprintf('$cookieParts should not contain the cookie delimiter "%s".', self::COOKIE_DELIMITER)); - } - } - - return base64_encode(implode(self::COOKIE_DELIMITER, $cookieParts)); - } - - /** - * Deletes the remember-me cookie. - */ - protected function cancelCookie(Request $request) - { - if (null !== $this->logger) { - $this->logger->debug('Clearing remember-me cookie.', ['name' => $this->options['name']]); - } - - $request->attributes->set(self::COOKIE_ATTR_NAME, new Cookie($this->options['name'], null, 1, $this->options['path'], $this->options['domain'], $this->options['secure'] ?? $request->isSecure(), $this->options['httponly'], false, $this->options['samesite'])); - } - - /** - * Checks whether remember-me capabilities were requested. - * - * @return bool - */ - protected function isRememberMeRequested(Request $request) - { - if (true === $this->options['always_remember_me']) { - return true; - } - - $parameter = ParameterBagUtils::getRequestParameterValue($request, $this->options['remember_me_parameter']); - - if (null === $parameter && null !== $this->logger) { - $this->logger->debug('Did not send remember-me cookie.', ['parameter' => $this->options['remember_me_parameter']]); - } - - return 'true' === $parameter || 'on' === $parameter || '1' === $parameter || 'yes' === $parameter || true === $parameter; - } -} diff --git a/RememberMe/PersistentRememberMeHandler.php b/RememberMe/PersistentRememberMeHandler.php index 2b8759a2..37ee6803 100644 --- a/RememberMe/PersistentRememberMeHandler.php +++ b/RememberMe/PersistentRememberMeHandler.php @@ -32,29 +32,27 @@ */ final class PersistentRememberMeHandler extends AbstractRememberMeHandler { - private $tokenProvider; - private $tokenVerifier; - - public function __construct(TokenProviderInterface $tokenProvider, string $secret, UserProviderInterface $userProvider, RequestStack $requestStack, array $options, ?LoggerInterface $logger = null, ?TokenVerifierInterface $tokenVerifier = null) - { + public function __construct( + private TokenProviderInterface $tokenProvider, + UserProviderInterface $userProvider, + RequestStack $requestStack, + array $options, + ?LoggerInterface $logger = null, + private ?TokenVerifierInterface $tokenVerifier = null, + ) { parent::__construct($userProvider, $requestStack, $options, $logger); if (!$tokenVerifier && $tokenProvider instanceof TokenVerifierInterface) { - $tokenVerifier = $tokenProvider; + $this->tokenVerifier = $tokenProvider; } - $this->tokenProvider = $tokenProvider; - $this->tokenVerifier = $tokenVerifier; } - /** - * {@inheritdoc} - */ public function createRememberMeCookie(UserInterface $user): void { $series = random_bytes(66); $tokenValue = strtr(base64_encode(substr($series, 33)), '+/=', '-_~'); $series = strtr(base64_encode(substr($series, 0, 33)), '+/=', '-_~'); - $token = new PersistentToken(\get_class($user), method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), $series, $tokenValue, new \DateTime()); + $token = new PersistentToken($user::class, $user->getUserIdentifier(), $series, $tokenValue, new \DateTimeImmutable()); $this->tokenProvider->createNewToken($token); $this->createCookie(RememberMeDetails::fromPersistentToken($token, time() + $this->options['lifetime'])); @@ -66,9 +64,16 @@ public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): U throw new AuthenticationException('The cookie is incorrectly formatted.'); } - [$series, $tokenValue] = explode(':', $rememberMeDetails->getValue()); + [$series, $tokenValue] = explode(':', $rememberMeDetails->getValue(), 2); $persistentToken = $this->tokenProvider->loadTokenBySeries($series); + if ($persistentToken->getUserIdentifier() !== $rememberMeDetails->getUserIdentifier() || $persistentToken->getClass() !== $rememberMeDetails->getUserFqcn()) { + throw new AuthenticationException('The cookie\'s hash is invalid.'); + } + + // content of $rememberMeDetails is not trustable. this prevents use of this class + unset($rememberMeDetails); + if ($this->tokenVerifier) { $isTokenValid = $this->tokenVerifier->verifyToken($persistentToken, $tokenValue); } else { @@ -78,17 +83,23 @@ public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): U throw new CookieTheftException('This token was already used. The account is possibly compromised.'); } - if ($persistentToken->getLastUsed()->getTimestamp() + $this->options['lifetime'] < time()) { + $expires = $persistentToken->getLastUsed()->getTimestamp() + $this->options['lifetime']; + if ($expires < time()) { throw new AuthenticationException('The cookie has expired.'); } - return parent::consumeRememberMeCookie($rememberMeDetails->withValue($persistentToken->getLastUsed()->getTimestamp().':'.$rememberMeDetails->getValue().':'.$persistentToken->getClass())); + return parent::consumeRememberMeCookie(new RememberMeDetails( + $persistentToken->getClass(), + $persistentToken->getUserIdentifier(), + $expires, + $persistentToken->getLastUsed()->getTimestamp().':'.$series.':'.$tokenValue.':'.$persistentToken->getClass() + )); } public function processRememberMe(RememberMeDetails $rememberMeDetails, UserInterface $user): void { [$lastUsed, $series, $tokenValue, $class] = explode(':', $rememberMeDetails->getValue(), 4); - $persistentToken = new PersistentToken($class, $rememberMeDetails->getUserIdentifier(), $series, $tokenValue, new \DateTime('@'.$lastUsed)); + $persistentToken = new PersistentToken($class, $rememberMeDetails->getUserIdentifier(), $series, $tokenValue, new \DateTimeImmutable('@'.$lastUsed)); // if a token was regenerated less than a minute ago, there is no need to regenerate it // if multiple concurrent requests reauthenticate a user we do not want to update the token several times @@ -98,17 +109,12 @@ public function processRememberMe(RememberMeDetails $rememberMeDetails, UserInte $tokenValue = strtr(base64_encode(random_bytes(33)), '+/=', '-_~'); $tokenLastUsed = new \DateTime(); - if ($this->tokenVerifier) { - $this->tokenVerifier->updateExistingToken($persistentToken, $tokenValue, $tokenLastUsed); - } + $this->tokenVerifier?->updateExistingToken($persistentToken, $tokenValue, $tokenLastUsed); $this->tokenProvider->updateToken($series, $tokenValue, $tokenLastUsed); $this->createCookie($rememberMeDetails->withValue($series.':'.$tokenValue)); } - /** - * {@inheritdoc} - */ public function clearRememberMeCookie(): void { parent::clearRememberMeCookie(); @@ -118,7 +124,12 @@ public function clearRememberMeCookie(): void return; } - $rememberMeDetails = RememberMeDetails::fromRawCookie($cookie); + try { + $rememberMeDetails = RememberMeDetails::fromRawCookie($cookie); + } catch (AuthenticationException) { + // malformed cookie should not fail the response and can be simply ignored + return; + } [$series] = explode(':', $rememberMeDetails->getValue()); $this->tokenProvider->deleteTokenBySeries($series); } diff --git a/RememberMe/PersistentTokenBasedRememberMeServices.php b/RememberMe/PersistentTokenBasedRememberMeServices.php deleted file mode 100644 index 2bf9d3c6..00000000 --- a/RememberMe/PersistentTokenBasedRememberMeServices.php +++ /dev/null @@ -1,167 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\RememberMe; - -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; -use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface; -use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\CookieTheftException; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" instead.', PersistentTokenBasedRememberMeServices::class, PersistentRememberMeHandler::class); - -/** - * Concrete implementation of the RememberMeServicesInterface which needs - * an implementation of TokenProviderInterface for providing remember-me - * capabilities. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.4, use {@see PersistentRememberMeHandler} instead - */ -class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices -{ - private const HASHED_TOKEN_PREFIX = 'sha256_'; - - /** @var TokenProviderInterface */ - private $tokenProvider; - - public function setTokenProvider(TokenProviderInterface $tokenProvider) - { - $this->tokenProvider = $tokenProvider; - } - - /** - * {@inheritdoc} - */ - protected function cancelCookie(Request $request) - { - // Delete cookie on the client - parent::cancelCookie($request); - - // Delete cookie from the tokenProvider - if (null !== ($cookie = $request->cookies->get($this->options['name'])) - && 2 === \count($parts = $this->decodeCookie($cookie)) - ) { - [$series] = $parts; - $this->tokenProvider->deleteTokenBySeries($series); - } - } - - /** - * {@inheritdoc} - */ - protected function processAutoLoginCookie(array $cookieParts, Request $request) - { - if (2 !== \count($cookieParts)) { - throw new AuthenticationException('The cookie is invalid.'); - } - - [$series, $tokenValue] = $cookieParts; - $persistentToken = $this->tokenProvider->loadTokenBySeries($series); - - if (!$this->isTokenValueValid($persistentToken, $tokenValue)) { - throw new CookieTheftException('This token was already used. The account is possibly compromised.'); - } - - if ($persistentToken->getLastUsed()->getTimestamp() + $this->options['lifetime'] < time()) { - throw new AuthenticationException('The cookie has expired.'); - } - - $tokenValue = base64_encode(random_bytes(64)); - $this->tokenProvider->updateToken($series, $this->generateHash($tokenValue), new \DateTime()); - $request->attributes->set(self::COOKIE_ATTR_NAME, - new Cookie( - $this->options['name'], - $this->encodeCookie([$series, $tokenValue]), - time() + $this->options['lifetime'], - $this->options['path'], - $this->options['domain'], - $this->options['secure'] ?? $request->isSecure(), - $this->options['httponly'], - false, - $this->options['samesite'] - ) - ); - - $userProvider = $this->getUserProvider($persistentToken->getClass()); - // @deprecated since Symfony 5.3, change to $persistentToken->getUserIdentifier() in 6.0 - if (method_exists($persistentToken, 'getUserIdentifier')) { - $userIdentifier = $persistentToken->getUserIdentifier(); - } else { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "getUserIdentifier()" in persistent token "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($persistentToken)); - - $userIdentifier = $persistentToken->getUsername(); - } - - // @deprecated since Symfony 5.3, change to $userProvider->loadUserByIdentifier() in 6.0 - if (method_exists($userProvider, 'loadUserByIdentifier')) { - return $userProvider->loadUserByIdentifier($userIdentifier); - } else { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($userProvider)); - - return $userProvider->loadUserByUsername($userIdentifier); - } - } - - /** - * {@inheritdoc} - */ - protected function onLoginSuccess(Request $request, Response $response, TokenInterface $token) - { - $series = base64_encode(random_bytes(64)); - $tokenValue = base64_encode(random_bytes(64)); - - $this->tokenProvider->createNewToken( - new PersistentToken( - \get_class($user = $token->getUser()), - // @deprecated since Symfony 5.3, change to $user->getUserIdentifier() in 6.0 - method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), - $series, - $this->generateHash($tokenValue), - new \DateTime() - ) - ); - - $response->headers->setCookie( - new Cookie( - $this->options['name'], - $this->encodeCookie([$series, $tokenValue]), - time() + $this->options['lifetime'], - $this->options['path'], - $this->options['domain'], - $this->options['secure'] ?? $request->isSecure(), - $this->options['httponly'], - false, - $this->options['samesite'] - ) - ); - } - - private function generateHash(string $tokenValue): string - { - return self::HASHED_TOKEN_PREFIX.hash_hmac('sha256', $tokenValue, $this->getSecret()); - } - - private function isTokenValueValid(PersistentTokenInterface $persistentToken, string $tokenValue): bool - { - if (0 === strpos($persistentToken->getTokenValue(), self::HASHED_TOKEN_PREFIX)) { - return hash_equals($persistentToken->getTokenValue(), $this->generateHash($tokenValue)); - } - - return hash_equals($persistentToken->getTokenValue(), $tokenValue); - } -} diff --git a/RememberMe/RememberMeDetails.php b/RememberMe/RememberMeDetails.php index 6aa65ec4..66f9a8f6 100644 --- a/RememberMe/RememberMeDetails.php +++ b/RememberMe/RememberMeDetails.php @@ -21,17 +21,12 @@ class RememberMeDetails { public const COOKIE_DELIMITER = ':'; - private $userFqcn; - private $userIdentifier; - private $expires; - private $value; - - public function __construct(string $userFqcn, string $userIdentifier, int $expires, string $value) - { - $this->userFqcn = $userFqcn; - $this->userIdentifier = $userIdentifier; - $this->expires = $expires; - $this->value = $value; + public function __construct( + private string $userFqcn, + private string $userIdentifier, + private int $expires, + private string $value, + ) { } public static function fromRawCookie(string $rawCookie): self diff --git a/RememberMe/RememberMeServicesInterface.php b/RememberMe/RememberMeServicesInterface.php deleted file mode 100644 index b97d17da..00000000 --- a/RememberMe/RememberMeServicesInterface.php +++ /dev/null @@ -1,79 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\RememberMe; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" interface is deprecated, use "%s" instead.', RememberMeServicesInterface::class, RememberMeHandlerInterface::class); - -/** - * Interface that needs to be implemented by classes which provide remember-me - * capabilities. - * - * We provide two implementations out-of-the-box: - * - TokenBasedRememberMeServices (does not require a TokenProvider) - * - PersistentTokenBasedRememberMeServices (requires a TokenProvider) - * - * @author Johannes M. Schmitt - * - * @method logout(Request $request, Response $response, TokenInterface $token) - * - * @deprecated since Symfony 5.4, use {@see RememberMeHandlerInterface} instead - */ -interface RememberMeServicesInterface -{ - /** - * This attribute name can be used by the implementation if it needs to set - * a cookie on the Request when there is no actual Response, yet. - */ - public const COOKIE_ATTR_NAME = '_security_remember_me_cookie'; - - /** - * This method will be called whenever the TokenStorage does not contain - * a TokenInterface object and the framework wishes to provide an implementation - * with an opportunity to authenticate the request using remember-me capabilities. - * - * No attempt whatsoever is made to determine whether the browser has requested - * remember-me services or presented a valid cookie. Any and all such determinations - * are left to the implementation of this method. - * - * If a browser has presented an unauthorised cookie for whatever reason, - * make sure to throw an AuthenticationException as this will consequentially - * result in a call to loginFail() and therefore an invalidation of the cookie. - * - * @return TokenInterface|null - */ - public function autoLogin(Request $request); - - /** - * Called whenever an interactive authentication attempt was made, but the - * credentials supplied by the user were missing or otherwise invalid. - * - * This method needs to take care of invalidating the cookie. - */ - public function loginFail(Request $request, ?\Exception $exception = null); - - /** - * Called whenever an interactive authentication attempt is successful - * (e.g. a form login). - * - * An implementation may always set a remember-me cookie in the Response, - * although this is not recommended. - * - * Instead, implementations should typically look for a request parameter - * (such as an HTTP POST parameter) that indicates the browser has explicitly - * requested for the authentication to be remembered. - */ - public function loginSuccess(Request $request, Response $response, TokenInterface $token); -} diff --git a/RememberMe/ResponseListener.php b/RememberMe/ResponseListener.php index 82eab696..e251a6cd 100644 --- a/RememberMe/ResponseListener.php +++ b/RememberMe/ResponseListener.php @@ -30,7 +30,7 @@ class ResponseListener implements EventSubscriberInterface */ public const COOKIE_ATTR_NAME = '_security_remember_me_cookie'; - public function onKernelResponse(ResponseEvent $event) + public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; @@ -44,9 +44,6 @@ public function onKernelResponse(ResponseEvent $event) } } - /** - * {@inheritdoc} - */ public static function getSubscribedEvents(): array { return [KernelEvents::RESPONSE => 'onKernelResponse']; diff --git a/RememberMe/SignatureRememberMeHandler.php b/RememberMe/SignatureRememberMeHandler.php index 0ccc856e..a8cfa25c 100644 --- a/RememberMe/SignatureRememberMeHandler.php +++ b/RememberMe/SignatureRememberMeHandler.php @@ -32,24 +32,22 @@ */ final class SignatureRememberMeHandler extends AbstractRememberMeHandler { - private $signatureHasher; - - public function __construct(SignatureHasher $signatureHasher, UserProviderInterface $userProvider, RequestStack $requestStack, array $options, ?LoggerInterface $logger = null) - { + public function __construct( + private SignatureHasher $signatureHasher, + UserProviderInterface $userProvider, + RequestStack $requestStack, + array $options, + ?LoggerInterface $logger = null, + ) { parent::__construct($userProvider, $requestStack, $options, $logger); - - $this->signatureHasher = $signatureHasher; } - /** - * {@inheritdoc} - */ public function createRememberMeCookie(UserInterface $user): void { $expires = time() + $this->options['lifetime']; $value = $this->signatureHasher->computeSignatureHash($user, $expires); - $details = new RememberMeDetails(\get_class($user), method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), $expires, $value); + $details = new RememberMeDetails($user::class, $user->getUserIdentifier(), $expires, $value); $this->createCookie($details); } diff --git a/RememberMe/TokenBasedRememberMeServices.php b/RememberMe/TokenBasedRememberMeServices.php deleted file mode 100644 index 2fa5966d..00000000 --- a/RememberMe/TokenBasedRememberMeServices.php +++ /dev/null @@ -1,136 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\RememberMe; - -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\User\UserInterface; - -trigger_deprecation('symfony/security-http', '5.4', 'The "%s" class is deprecated, use "%s" instead.', TokenBasedRememberMeServices::class, SignatureRememberMeHandler::class); - -/** - * Concrete implementation of the RememberMeServicesInterface providing - * remember-me capabilities without requiring a TokenProvider. - * - * @author Johannes M. Schmitt - * - * @deprecated since Symfony 5.4, use {@see SignatureRememberMeHandler} instead - */ -class TokenBasedRememberMeServices extends AbstractRememberMeServices -{ - /** - * {@inheritdoc} - */ - protected function processAutoLoginCookie(array $cookieParts, Request $request) - { - if (4 !== \count($cookieParts)) { - throw new AuthenticationException('The cookie is invalid.'); - } - - [$class, $userIdentifier, $expires, $hash] = $cookieParts; - if (false === $userIdentifier = base64_decode($userIdentifier, true)) { - throw new AuthenticationException('$userIdentifier contains a character from outside the base64 alphabet.'); - } - try { - $userProvider = $this->getUserProvider($class); - // @deprecated since Symfony 5.3, change to $userProvider->loadUserByIdentifier() in 6.0 - if (method_exists($userProvider, 'loadUserByIdentifier')) { - $user = $userProvider->loadUserByIdentifier($userIdentifier); - } else { - trigger_deprecation('symfony/security-core', '5.3', 'Not implementing method "loadUserByIdentifier()" in user provider "%s" is deprecated. This method will replace "loadUserByUsername()" in Symfony 6.0.', get_debug_type($userProvider)); - - $user = $userProvider->loadUserByUsername($userIdentifier); - } - } catch (\Exception $e) { - if (!$e instanceof AuthenticationException) { - $e = new AuthenticationException($e->getMessage(), $e->getCode(), $e); - } - - throw $e; - } - - if (!$user instanceof UserInterface) { - throw new \RuntimeException(sprintf('The UserProviderInterface implementation must return an instance of UserInterface, but returned "%s".', get_debug_type($user))); - } - - if (true !== hash_equals($this->generateCookieHash($class, $userIdentifier, $expires, $user->getPassword()), $hash)) { - throw new AuthenticationException('The cookie\'s hash is invalid.'); - } - - if ($expires < time()) { - throw new AuthenticationException('The cookie has expired.'); - } - - return $user; - } - - /** - * {@inheritdoc} - */ - protected function onLoginSuccess(Request $request, Response $response, TokenInterface $token) - { - $user = $token->getUser(); - $expires = time() + $this->options['lifetime']; - // @deprecated since Symfony 5.3, change to $user->getUserIdentifier() in 6.0 - $value = $this->generateCookieValue(\get_class($user), method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), $expires, $user->getPassword()); - - $response->headers->setCookie( - new Cookie( - $this->options['name'], - $value, - $expires, - $this->options['path'], - $this->options['domain'], - $this->options['secure'] ?? $request->isSecure(), - $this->options['httponly'], - false, - $this->options['samesite'] - ) - ); - } - - /** - * Generates the cookie value. - * - * @param int $expires The Unix timestamp when the cookie expires - * @param string|null $password The encoded password - * - * @return string - */ - protected function generateCookieValue(string $class, string $userIdentifier, int $expires, ?string $password) - { - // $userIdentifier is encoded because it might contain COOKIE_DELIMITER, - // we assume other values don't - return $this->encodeCookie([ - $class, - base64_encode($userIdentifier), - $expires, - $this->generateCookieHash($class, $userIdentifier, $expires, $password), - ]); - } - - /** - * Generates a hash for the cookie to ensure it is not being tampered with. - * - * @param int $expires The Unix timestamp when the cookie expires - * @param string|null $password The encoded password - * - * @return string - */ - protected function generateCookieHash(string $class, string $userIdentifier, int $expires, ?string $password) - { - return hash_hmac('sha256', $class.self::COOKIE_DELIMITER.$userIdentifier.self::COOKIE_DELIMITER.$expires.self::COOKIE_DELIMITER.$password, $this->getSecret()); - } -} diff --git a/SecurityRequestAttributes.php b/SecurityRequestAttributes.php new file mode 100644 index 00000000..1c71ea07 --- /dev/null +++ b/SecurityRequestAttributes.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http; + +/** + * List of request attributes used along the security flow. + * + * @author Robin Chalas + */ +final class SecurityRequestAttributes +{ + public const ACCESS_DENIED_ERROR = '_security.403_error'; + public const AUTHENTICATION_ERROR = '_security.last_error'; + public const LAST_USERNAME = '_security.last_username'; +} diff --git a/Session/SessionAuthenticationStrategy.php b/Session/SessionAuthenticationStrategy.php index f7688ca1..615e7e74 100644 --- a/Session/SessionAuthenticationStrategy.php +++ b/Session/SessionAuthenticationStrategy.php @@ -31,11 +31,12 @@ class SessionAuthenticationStrategy implements SessionAuthenticationStrategyInte public const MIGRATE = 'migrate'; public const INVALIDATE = 'invalidate'; - private $strategy; - private $csrfTokenStorage = null; + private ?ClearableTokenStorageInterface $csrfTokenStorage = null; - public function __construct(string $strategy, ?ClearableTokenStorageInterface $csrfTokenStorage = null) - { + public function __construct( + private string $strategy, + ?ClearableTokenStorageInterface $csrfTokenStorage = null, + ) { $this->strategy = $strategy; if (self::MIGRATE === $strategy) { @@ -43,10 +44,7 @@ public function __construct(string $strategy, ?ClearableTokenStorageInterface $c } } - /** - * {@inheritdoc} - */ - public function onAuthentication(Request $request, TokenInterface $token) + public function onAuthentication(Request $request, TokenInterface $token): void { switch ($this->strategy) { case self::NONE: @@ -54,10 +52,7 @@ public function onAuthentication(Request $request, TokenInterface $token) case self::MIGRATE: $request->getSession()->migrate(true); - - if ($this->csrfTokenStorage) { - $this->csrfTokenStorage->clear(); - } + $this->csrfTokenStorage?->clear(); return; @@ -67,7 +62,7 @@ public function onAuthentication(Request $request, TokenInterface $token) return; default: - throw new \RuntimeException(sprintf('Invalid session authentication strategy "%s".', $this->strategy)); + throw new \RuntimeException(\sprintf('Invalid session authentication strategy "%s".', $this->strategy)); } } } diff --git a/Session/SessionAuthenticationStrategyInterface.php b/Session/SessionAuthenticationStrategyInterface.php index a45f852d..3eb3a4bf 100644 --- a/Session/SessionAuthenticationStrategyInterface.php +++ b/Session/SessionAuthenticationStrategyInterface.php @@ -30,5 +30,5 @@ interface SessionAuthenticationStrategyInterface * This method should be called before the TokenStorage is populated with a * Token. It should be used by authentication listeners when a session is used. */ - public function onAuthentication(Request $request, TokenInterface $token); + public function onAuthentication(Request $request, TokenInterface $token): void; } diff --git a/Tests/AccessToken/Cas/Cas2HandlerTest.php b/Tests/AccessToken/Cas/Cas2HandlerTest.php new file mode 100644 index 00000000..728b7ca5 --- /dev/null +++ b/Tests/AccessToken/Cas/Cas2HandlerTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\Cas; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\AccessToken\Cas\Cas2Handler; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +final class Cas2HandlerTest extends TestCase +{ + public function testWithValidTicket() + { + $response = new MockResponse(<< + + lobster + PGTIOU-84678-8a9d + + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'PGTIOU-84678-8a9d'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $userbadge = $cas2Handler->getUserBadgeFrom('PGTIOU-84678-8a9d'); + $this->assertEquals(new UserBadge('lobster'), $userbadge); + } + + public function testWithInvalidTicket() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('CAS Authentication Failure: Ticket ST-1856339 not recognized'); + + $response = new MockResponse(<< + + Ticket ST-1856339 not recognized + + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'ST-1856339'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $cas2Handler->getUserBadgeFrom('should-not-work'); + } + + public function testWithInvalidCasResponse() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid CAS response.'); + + $response = new MockResponse(<< + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'ST-1856339'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $cas2Handler->getUserBadgeFrom('should-not-work'); + } + + public function testWithoutTicket() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('No ticket found in request.'); + + $httpClient = new MockHttpClient(); + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $cas2Handler->getUserBadgeFrom('should-not-work'); + } + + public function testWithInvalidPrefix() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid CAS response.'); + + $response = new MockResponse(<< + + lobster + PGTIOU-84678-8a9d + + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'PGTIOU-84678-8a9d'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', prefix: 'invalid-one', client: $httpClient); + $username = $cas2Handler->getUserBadgeFrom('PGTIOU-84678-8a9d'); + $this->assertEquals('lobster', $username); + } +} diff --git a/Tests/AccessToken/OAuth2/OAuth2TokenHandlerTest.php b/Tests/AccessToken/OAuth2/OAuth2TokenHandlerTest.php new file mode 100644 index 00000000..c6538ff7 --- /dev/null +++ b/Tests/AccessToken/OAuth2/OAuth2TokenHandlerTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\OAuth2; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Security\Core\User\OAuth2User; +use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +class OAuth2TokenHandlerTest extends TestCase +{ + public static function testGetsUserIdentifierFromOAuth2ServerResponse(): void + { + $accessToken = 'a-secret-token'; + $claims = [ + 'active' => true, + 'client_id' => 'l238j323ds-23ij4', + 'username' => 'jdoe', + 'scope' => 'read write dolphin', + 'sub' => 'Z5O3upPC88QrAjx00dis', + 'aud' => 'https://protected.example.net/resource', + 'iss' => 'https://server.example.com/', + 'exp' => 1419356238, + 'iat' => 1419350238, + 'extension_field' => 'twenty-seven', + ]; + $expectedUser = new OAuth2User(...$claims); + + $client = new MockHttpClient([ + new MockResponse(json_encode($claims, \JSON_THROW_ON_ERROR)), + ]); + + $userBadge = (new Oauth2TokenHandler($client))->getUserBadgeFrom($accessToken); + $actualUser = $userBadge->getUserLoader()(); + + self::assertEquals(new UserBadge('Z5O3upPC88QrAjx00dis', fn () => $expectedUser, $claims), $userBadge); + self::assertInstanceOf(OAuth2User::class, $actualUser); + self::assertSame($claims, $userBadge->getAttributes()); + self::assertSame($claims['sub'], $actualUser->getUserIdentifier()); + } +} diff --git a/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php b/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php new file mode 100644 index 00000000..303fa561 --- /dev/null +++ b/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\Oidc; + +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWK; +use Jose\Component\Core\JWKSet; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\OidcUser; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +/** + * @requires extension openssl + */ +class OidcTokenHandlerTest extends TestCase +{ + private const AUDIENCE = 'Symfony OIDC'; + + /** + * @dataProvider getClaims + */ + public function testGetsUserIdentifierFromSignedToken(string $claim, string $expected) + { + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com', + 'aud' => self::AUDIENCE, + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ]; + $token = $this->buildJWS(json_encode($claims)); + $expectedUser = new OidcUser(...$claims, userIdentifier: $claims[$claim]); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->never())->method('error'); + + $userBadge = (new OidcTokenHandler( + new AlgorithmManager([new ES256()]), + $this->getJWKSet(), + self::AUDIENCE, + ['https://www.example.com'], + $claim, + $loggerMock, + ))->getUserBadgeFrom($token); + $actualUser = $userBadge->getUserLoader()(); + + $this->assertEquals(new UserBadge($expected, new FallbackUserLoader(fn () => $expectedUser), $claims), $userBadge); + $this->assertInstanceOf(OidcUser::class, $actualUser); + $this->assertEquals($expectedUser, $actualUser); + $this->assertEquals($claims, $userBadge->getAttributes()); + $this->assertEquals($claims[$claim], $actualUser->getUserIdentifier()); + } + + public static function getClaims(): iterable + { + yield ['sub', 'e21bf182-1538-406e-8ccb-e25a17aba39f']; + yield ['email', 'foo@example.com']; + } + + /** + * @dataProvider getInvalidTokens + */ + public function testThrowsAnErrorIfTokenIsInvalid(string $token) + { + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once())->method('error'); + + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + (new OidcTokenHandler( + new AlgorithmManager([new ES256()]), + $this->getJWKSet(), + self::AUDIENCE, + ['https://www.example.com'], + 'sub', + $loggerMock, + ))->getUserBadgeFrom($token); + } + + public static function getInvalidTokens(): iterable + { + // Invalid token + yield ['invalid']; + // Token is expired + yield [ + self::buildJWS(json_encode([ + 'iat' => time() - 3600, + 'nbf' => time() - 3600, + 'exp' => time() - 3590, + 'iss' => 'https://www.example.com', + 'aud' => self::AUDIENCE, + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ])), + ]; + // Invalid audience + yield [ + self::buildJWS(json_encode([ + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 3590, + 'iss' => 'https://www.example.com', + 'aud' => 'invalid', + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ])), + ]; + } + + public function testThrowsAnErrorIfUserPropertyIsMissing() + { + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once())->method('error'); + + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com', + 'aud' => self::AUDIENCE, + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + ]; + $token = $this->buildJWS(json_encode($claims)); + + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + (new OidcTokenHandler( + new AlgorithmManager([new ES256()]), + self::getJWKSet(), + self::AUDIENCE, + ['https://www.example.com'], + 'email', + $loggerMock, + ))->getUserBadgeFrom($token); + } + + private static function buildJWS(string $payload): string + { + return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([ + new ES256(), + ])))->create() + ->withPayload($payload) + ->addSignature(self::getJWK(), ['alg' => 'ES256']) + ->build() + ); + } + + private static function getJWK(): JWK + { + // tip: use https://mkjwk.org/ to generate a JWK + return new JWK([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => '0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4', + 'y' => 'KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo', + 'd' => 'iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220', + ]); + } + + private static function getJWKSet(): JWKSet + { + return new JWKSet([ + new JWK([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars', + 'y' => 'rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY', + 'd' => '4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0', + ]), + self::getJWK(), + ]); + } +} diff --git a/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php b/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php new file mode 100644 index 00000000..b141368f --- /dev/null +++ b/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\Oidc; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\OidcUser; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class OidcUserInfoTokenHandlerTest extends TestCase +{ + /** + * @dataProvider getClaims + */ + public function testGetsUserIdentifierFromOidcServerResponse(string $claim, string $expected) + { + $accessToken = 'a-secret-token'; + $claims = [ + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ]; + $expectedUser = new OidcUser(...$claims, userIdentifier: $claims[$claim]); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once()) + ->method('toArray') + ->willReturn($claims); + + $clientMock = $this->createMock(HttpClientInterface::class); + $clientMock->expects($this->once()) + ->method('request')->with('GET', '', ['auth_bearer' => $accessToken]) + ->willReturn($responseMock); + + $userBadge = (new OidcUserInfoTokenHandler($clientMock, null, $claim))->getUserBadgeFrom($accessToken); + $actualUser = $userBadge->getUserLoader()(); + + $this->assertEquals(new UserBadge($expected, new FallbackUserLoader(fn () => $expectedUser), $claims), $userBadge); + $this->assertInstanceOf(OidcUser::class, $actualUser); + $this->assertEquals($expectedUser, $actualUser); + $this->assertEquals($claims, $userBadge->getAttributes()); + $this->assertEquals($claims[$claim], $actualUser->getUserIdentifier()); + } + + public static function getClaims(): iterable + { + yield ['sub', 'e21bf182-1538-406e-8ccb-e25a17aba39f']; + yield ['email', 'foo@example.com']; + } + + public function testThrowsAnExceptionIfUserPropertyIsMissing() + { + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once()) + ->method('toArray') + ->willReturn(['foo' => 'bar']); + + $clientMock = $this->createMock(HttpClientInterface::class); + $clientMock->expects($this->once()) + ->method('request')->with('GET', '', ['auth_bearer' => 'a-secret-token']) + ->willReturn($responseMock); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once()) + ->method('error'); + + $handler = new OidcUserInfoTokenHandler($clientMock, $loggerMock); + + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $handler->getUserBadgeFrom('a-secret-token'); + } +} diff --git a/Tests/Authentication/AuthenticationUtilsTest.php b/Tests/Authentication/AuthenticationUtilsTest.php index 7474570e..b0c37fce 100644 --- a/Tests/Authentication/AuthenticationUtilsTest.php +++ b/Tests/Authentication/AuthenticationUtilsTest.php @@ -17,8 +17,8 @@ use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; +use Symfony\Component\Security\Http\SecurityRequestAttributes; class AuthenticationUtilsTest extends TestCase { @@ -26,7 +26,7 @@ public function testLastAuthenticationErrorWhenRequestHasAttribute() { $authenticationError = new AuthenticationException(); $request = Request::create('/'); - $request->attributes->set(Security::AUTHENTICATION_ERROR, $authenticationError); + $request->attributes->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $authenticationError); $requestStack = new RequestStack(); $requestStack->push($request); @@ -42,7 +42,7 @@ public function testLastAuthenticationErrorInSession() $request = Request::create('/'); $session = new Session(new MockArraySessionStorage()); - $session->set(Security::AUTHENTICATION_ERROR, $authenticationError); + $session->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $authenticationError); $request->setSession($session); $requestStack = new RequestStack(); @@ -50,7 +50,7 @@ public function testLastAuthenticationErrorInSession() $utils = new AuthenticationUtils($requestStack); $this->assertSame($authenticationError, $utils->getLastAuthenticationError()); - $this->assertFalse($session->has(Security::AUTHENTICATION_ERROR)); + $this->assertFalse($session->has(SecurityRequestAttributes::AUTHENTICATION_ERROR)); } public function testLastAuthenticationErrorInSessionWithoutClearing() @@ -60,7 +60,7 @@ public function testLastAuthenticationErrorInSessionWithoutClearing() $request = Request::create('/'); $session = new Session(new MockArraySessionStorage()); - $session->set(Security::AUTHENTICATION_ERROR, $authenticationError); + $session->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $authenticationError); $request->setSession($session); $requestStack = new RequestStack(); @@ -68,13 +68,13 @@ public function testLastAuthenticationErrorInSessionWithoutClearing() $utils = new AuthenticationUtils($requestStack); $this->assertSame($authenticationError, $utils->getLastAuthenticationError(false)); - $this->assertTrue($session->has(Security::AUTHENTICATION_ERROR)); + $this->assertTrue($session->has(SecurityRequestAttributes::AUTHENTICATION_ERROR)); } public function testLastUserNameIsDefinedButNull() { $request = Request::create('/'); - $request->attributes->set(Security::LAST_USERNAME, null); + $request->attributes->set(SecurityRequestAttributes::LAST_USERNAME, null); $requestStack = new RequestStack(); $requestStack->push($request); @@ -86,7 +86,7 @@ public function testLastUserNameIsDefinedButNull() public function testLastUserNameIsDefined() { $request = Request::create('/'); - $request->attributes->set(Security::LAST_USERNAME, 'user'); + $request->attributes->set(SecurityRequestAttributes::LAST_USERNAME, 'user'); $requestStack = new RequestStack(); $requestStack->push($request); @@ -100,7 +100,7 @@ public function testLastUserNameIsDefinedInSessionButNull() $request = Request::create('/'); $session = new Session(new MockArraySessionStorage()); - $session->set(Security::LAST_USERNAME, null); + $session->set(SecurityRequestAttributes::LAST_USERNAME, null); $request->setSession($session); $requestStack = new RequestStack(); @@ -115,7 +115,7 @@ public function testLastUserNameIsDefinedInSession() $request = Request::create('/'); $session = new Session(new MockArraySessionStorage()); - $session->set(Security::LAST_USERNAME, 'user'); + $session->set(SecurityRequestAttributes::LAST_USERNAME, 'user'); $request->setSession($session); $requestStack = new RequestStack(); diff --git a/Tests/Authentication/AuthenticatorManagerBCTest.php b/Tests/Authentication/AuthenticatorManagerBCTest.php new file mode 100644 index 00000000..9775e5a1 --- /dev/null +++ b/Tests/Authentication/AuthenticatorManagerBCTest.php @@ -0,0 +1,488 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authentication; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Exception\LockedException; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; +use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; +use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Event\AuthenticationTokenCreatedEvent; +use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\Tests\Fixtures\DummySupportsAuthenticator; + +class AuthenticatorManagerBCTest extends TestCase +{ + use ExpectUserDeprecationMessageTrait; + + private MockObject&TokenStorageInterface $tokenStorage; + private EventDispatcher $eventDispatcher; + private Request $request; + private InMemoryUser $user; + private MockObject&TokenInterface $token; + private Response $response; + + protected function setUp(): void + { + $this->tokenStorage = $this->createMock(TokenStorageInterface::class); + $this->eventDispatcher = new EventDispatcher(); + $this->request = new Request(); + $this->user = new InMemoryUser('wouter', null); + $this->token = $this->createMock(TokenInterface::class); + $this->token->expects($this->any())->method('getUser')->willReturn($this->user); + $this->response = $this->createMock(Response::class); + } + + /** + * @dataProvider provideSupportsData + * + * @group legacy + */ + public function testSupports($authenticators, $result) + { + $manager = $this->createManager($authenticators, hideUserNotFoundExceptions: true); + + $this->assertEquals($result, $manager->supports($this->request)); + } + + public static function provideSupportsData() + { + yield [[self::createDummySupportsAuthenticator(null), self::createDummySupportsAuthenticator(null)], null]; + yield [[self::createDummySupportsAuthenticator(null), self::createDummySupportsAuthenticator(false)], null]; + + yield [[self::createDummySupportsAuthenticator(null), self::createDummySupportsAuthenticator(true)], true]; + yield [[self::createDummySupportsAuthenticator(true), self::createDummySupportsAuthenticator(false)], true]; + + yield [[self::createDummySupportsAuthenticator(false), self::createDummySupportsAuthenticator(false)], false]; + yield [[], false]; + } + + /** + * @group legacy + */ + public function testSupportsInvalidAuthenticator() + { + $manager = $this->createManager([new \stdClass()], hideUserNotFoundExceptions: true); + + $this->expectExceptionObject( + new \InvalidArgumentException('Authenticator "stdClass" must implement "Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface".') + ); + + $manager->supports($this->request); + } + + /** + * @group legacy + */ + public function testSupportCheckedUponRequestAuthentication() + { + // the attribute stores the supported authenticators, returning false now + // means support changed between calling supports() and authenticateRequest() + // (which is the case with lazy firewalls) + $authenticator = $this->createAuthenticator(false); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->never())->method('authenticate'); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $manager->authenticateRequest($this->request); + } + + /** + * @dataProvider provideMatchingAuthenticatorIndex + * + * @group legacy + */ + public function testAuthenticateRequest($matchingAuthenticatorIndex) + { + $authenticators = [$this->createAuthenticator(0 === $matchingAuthenticatorIndex), $this->createAuthenticator(1 === $matchingAuthenticatorIndex)]; + $this->request->attributes->set('_security_authenticators', $authenticators); + $matchingAuthenticator = $authenticators[$matchingAuthenticatorIndex]; + + $authenticators[($matchingAuthenticatorIndex + 1) % 2]->expects($this->never())->method('authenticate'); + + $matchingAuthenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + + $listenerCalled = false; + $this->eventDispatcher->addListener(CheckPassportEvent::class, function (CheckPassportEvent $event) use (&$listenerCalled, $matchingAuthenticator) { + if ($event->getAuthenticator() === $matchingAuthenticator && $event->getPassport()->getUser() === $this->user) { + $listenerCalled = true; + } + }); + $matchingAuthenticator->expects($this->any())->method('createToken')->willReturn($this->token); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $manager = $this->createManager($authenticators, hideUserNotFoundExceptions: true); + $this->assertNull($manager->authenticateRequest($this->request)); + $this->assertTrue($listenerCalled, 'The CheckPassportEvent listener is not called'); + } + + public static function provideMatchingAuthenticatorIndex() + { + yield [0]; + yield [1]; + } + + /** + * @group legacy + */ + public function testNoCredentialsValidated() + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new Passport(new UserBadge('wouter', fn () => $this->user), new PasswordCredentials('pass'))); + + $authenticator->expects($this->once()) + ->method('onAuthenticationFailure') + ->with($this->request, $this->isInstanceOf(BadCredentialsException::class)); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $manager->authenticateRequest($this->request); + } + + /** + * @group legacy + */ + public function testRequiredBadgeMissing() + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter'))); + + $authenticator->expects($this->once())->method('onAuthenticationFailure')->with($this->anything(), $this->callback(fn ($exception) => 'Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "'.CsrfTokenBadge::class.'".' === $exception->getMessage())); + + $manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class], hideUserNotFoundExceptions: true); + $manager->authenticateRequest($this->request); + } + + /** + * @group legacy + */ + public function testAllRequiredBadgesPresent() + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $csrfBadge = new CsrfTokenBadge('csrfid', 'csrftoken'); + $csrfBadge->markResolved(); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter'), [$csrfBadge])); + $authenticator->expects($this->any())->method('createToken')->willReturn(new UsernamePasswordToken($this->user, 'main')); + + $authenticator->expects($this->once())->method('onAuthenticationSuccess'); + + $manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class], hideUserNotFoundExceptions: true); + $manager->authenticateRequest($this->request); + } + + /** + * @dataProvider provideEraseCredentialsData + * + * @group legacy + */ + public function testEraseCredentials($eraseCredentials) + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + + $this->token->expects($eraseCredentials ? $this->once() : $this->never())->method('eraseCredentials'); + + $manager = $this->createManager([$authenticator], 'main', $eraseCredentials, hideUserNotFoundExceptions: true); + $manager->authenticateRequest($this->request); + } + + public static function provideEraseCredentialsData() + { + yield [true]; + yield [false]; + } + + /** + * @group legacy + */ + public function testAuthenticateRequestCanModifyTokenFromEvent() + { + $authenticator = $this->createAuthenticator(); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + + $modifiedToken = $this->createMock(TokenInterface::class); + $modifiedToken->expects($this->any())->method('getUser')->willReturn($this->user); + $listenerCalled = false; + $this->eventDispatcher->addListener(AuthenticationTokenCreatedEvent::class, function (AuthenticationTokenCreatedEvent $event) use (&$listenerCalled, $modifiedToken) { + $event->setAuthenticatedToken($modifiedToken); + $listenerCalled = true; + }); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->identicalTo($modifiedToken)); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $this->assertNull($manager->authenticateRequest($this->request)); + $this->assertTrue($listenerCalled, 'The AuthenticationTokenCreatedEvent listener is not called'); + } + + /** + * @group legacy + */ + public function testAuthenticateUser() + { + $authenticator = $this->createAuthenticator(); + $authenticator->expects($this->any())->method('onAuthenticationSuccess')->willReturn($this->response); + + $badge = new UserBadge('alex'); + + $authenticator + ->expects($this->any()) + ->method('createToken') + ->willReturnCallback(function (Passport $passport) use ($badge) { + $this->assertSame(['attr' => 'foo', 'attr2' => 'bar'], $passport->getAttributes()); + $this->assertSame([UserBadge::class => $badge], $passport->getBadges()); + + return $this->token; + }); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $manager->authenticateUser($this->user, $authenticator, $this->request, [$badge], ['attr' => 'foo', 'attr2' => 'bar']); + } + + /** + * @group legacy + */ + public function testAuthenticateUserCanModifyTokenFromEvent() + { + $authenticator = $this->createAuthenticator(); + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + $authenticator->expects($this->any())->method('onAuthenticationSuccess')->willReturn($this->response); + + $modifiedToken = $this->createMock(TokenInterface::class); + $modifiedToken->expects($this->any())->method('getUser')->willReturn($this->user); + $listenerCalled = false; + $this->eventDispatcher->addListener(AuthenticationTokenCreatedEvent::class, function (AuthenticationTokenCreatedEvent $event) use (&$listenerCalled, $modifiedToken) { + $event->setAuthenticatedToken($modifiedToken); + $listenerCalled = true; + }); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->identicalTo($modifiedToken)); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $manager->authenticateUser($this->user, $authenticator, $this->request); + $this->assertTrue($listenerCalled, 'The AuthenticationTokenCreatedEvent listener is not called'); + } + + /** + * @group legacy + */ + public function testInteractiveAuthenticator() + { + $authenticator = $this->createMock(TestInteractiveBCAuthenticator::class); + $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $authenticator->expects($this->any()) + ->method('onAuthenticationSuccess') + ->with($this->anything(), $this->token, 'main') + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + /** + * @group legacy + */ + public function testLegacyInteractiveAuthenticator() + { + $authenticator = $this->createMock(InteractiveAuthenticatorInterface::class); + $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $authenticator->expects($this->any()) + ->method('onAuthenticationSuccess') + ->with($this->anything(), $this->token, 'main') + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + /** + * @group legacy + */ + public function testAuthenticateRequestHidesInvalidUserExceptions() + { + $invalidUserException = new UserNotFoundException(); + $authenticator = $this->createMock(TestInteractiveBCAuthenticator::class); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willThrowException($invalidUserException); + + $authenticator->expects($this->any()) + ->method('onAuthenticationFailure') + ->with($this->equalTo($this->request), $this->callback(fn ($e) => $e instanceof BadCredentialsException && $invalidUserException === $e->getPrevious())) + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + /** + * @group legacy + */ + public function testAuthenticateRequestShowsAccountStatusException() + { + $invalidUserException = new LockedException(); + $authenticator = $this->createMock(TestInteractiveBCAuthenticator::class); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willThrowException($invalidUserException); + + $authenticator->expects($this->any()) + ->method('onAuthenticationFailure') + ->with($this->equalTo($this->request), $this->callback(fn ($e) => $e === $invalidUserException)) + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: false); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + /** + * @group legacy + */ + public function testAuthenticateRequestHidesInvalidAccountStatusException() + { + $invalidUserException = new LockedException(); + $authenticator = $this->createMock(TestInteractiveBCAuthenticator::class); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willThrowException($invalidUserException); + + $authenticator->expects($this->any()) + ->method('onAuthenticationFailure') + ->with($this->equalTo($this->request), $this->callback(fn ($e) => $e instanceof BadCredentialsException && $invalidUserException === $e->getPrevious())) + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], hideUserNotFoundExceptions: true); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + /** + * @group legacy + */ + public function testLogsUseTheDecoratedAuthenticatorWhenItIsTraceable() + { + $authenticator = $this->createMock(TestInteractiveBCAuthenticator::class); + $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); + $this->request->attributes->set('_security_authenticators', [new TraceableAuthenticator($authenticator)]); + + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); + + $authenticator->expects($this->any()) + ->method('onAuthenticationSuccess') + ->with($this->anything(), $this->token, 'main') + ->willReturn($this->response); + + $authenticator->expects($this->any()) + ->method('onAuthenticationSuccess') + ->with($this->anything(), $this->token, 'main') + ->willReturn($this->response); + + $logger = new class extends AbstractLogger { + public array $logContexts = []; + + public function log($level, $message, array $context = []): void + { + if ($context['authenticator'] ?? false) { + $this->logContexts[] = $context; + } + } + }; + + $manager = $this->createManager([$authenticator], 'main', true, [], $logger, hideUserNotFoundExceptions: true); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + $this->assertStringContainsString($authenticator::class, $logger->logContexts[0]['authenticator']); + } + + private function createAuthenticator(?bool $supports = true) + { + $authenticator = $this->createMock(TestInteractiveBCAuthenticator::class); + $authenticator->expects($this->any())->method('supports')->willReturn($supports); + + return $authenticator; + } + + private static function createDummySupportsAuthenticator(?bool $supports = true) + { + return new DummySupportsAuthenticator($supports); + } + + private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = true, array $requiredBadges = [], ?LoggerInterface $logger = null, bool $hideUserNotFoundExceptions = true) + { + $this->expectUserDeprecationMessage('Since symfony/security-http 7.3: Passing a boolean as "exposeSecurityErrors" parameter is deprecated, use Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel value instead.'); + + return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, $logger, $eraseCredentials, $hideUserNotFoundExceptions, $requiredBadges); + } +} + +abstract class TestInteractiveBCAuthenticator implements InteractiveAuthenticatorInterface +{ + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + } +} diff --git a/Tests/Authentication/AuthenticatorManagerTest.php b/Tests/Authentication/AuthenticatorManagerTest.php index c2c6682f..67f7247f 100644 --- a/Tests/Authentication/AuthenticatorManagerTest.php +++ b/Tests/Authentication/AuthenticatorManagerTest.php @@ -11,19 +11,24 @@ namespace Symfony\Component\Security\Http\Tests\Authentication; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Exception\LockedException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; +use Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel; use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; @@ -37,12 +42,14 @@ class AuthenticatorManagerTest extends TestCase { - private $tokenStorage; - private $eventDispatcher; - private $request; - private $user; - private $token; - private $response; + use ExpectUserDeprecationMessageTrait; + + private MockObject&TokenStorageInterface $tokenStorage; + private EventDispatcher $eventDispatcher; + private Request $request; + private InMemoryUser $user; + private MockObject&TokenInterface $token; + private Response $response; protected function setUp(): void { @@ -60,7 +67,7 @@ protected function setUp(): void */ public function testSupports($authenticators, $result) { - $manager = $this->createManager($authenticators); + $manager = $this->createManager($authenticators, exposeSecurityErrors: ExposeSecurityLevel::None); $this->assertEquals($result, $manager->supports($this->request)); } @@ -77,6 +84,17 @@ public static function provideSupportsData() yield [[], false]; } + public function testSupportsInvalidAuthenticator() + { + $manager = $this->createManager([new \stdClass()], exposeSecurityErrors: ExposeSecurityLevel::None); + + $this->expectExceptionObject( + new \InvalidArgumentException('Authenticator "stdClass" must implement "Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface".') + ); + + $manager->supports($this->request); + } + public function testSupportCheckedUponRequestAuthentication() { // the attribute stores the supported authenticators, returning false now @@ -87,7 +105,7 @@ public function testSupportCheckedUponRequestAuthentication() $authenticator->expects($this->never())->method('authenticate'); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $manager->authenticateRequest($this->request); } @@ -102,7 +120,7 @@ public function testAuthenticateRequest($matchingAuthenticatorIndex) $authenticators[($matchingAuthenticatorIndex + 1) % 2]->expects($this->never())->method('authenticate'); - $matchingAuthenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); + $matchingAuthenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); $listenerCalled = false; $this->eventDispatcher->addListener(CheckPassportEvent::class, function (CheckPassportEvent $event) use (&$listenerCalled, $matchingAuthenticator) { @@ -114,7 +132,7 @@ public function testAuthenticateRequest($matchingAuthenticatorIndex) $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); - $manager = $this->createManager($authenticators); + $manager = $this->createManager($authenticators, exposeSecurityErrors: ExposeSecurityLevel::None); $this->assertNull($manager->authenticateRequest($this->request)); $this->assertTrue($listenerCalled, 'The CheckPassportEvent listener is not called'); } @@ -130,13 +148,13 @@ public function testNoCredentialsValidated() $authenticator = $this->createAuthenticator(); $this->request->attributes->set('_security_authenticators', [$authenticator]); - $authenticator->expects($this->any())->method('authenticate')->willReturn(new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials('pass'))); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new Passport(new UserBadge('wouter', fn () => $this->user), new PasswordCredentials('pass'))); $authenticator->expects($this->once()) ->method('onAuthenticationFailure') ->with($this->request, $this->isInstanceOf(BadCredentialsException::class)); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $manager->authenticateRequest($this->request); } @@ -147,11 +165,9 @@ public function testRequiredBadgeMissing() $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter'))); - $authenticator->expects($this->once())->method('onAuthenticationFailure')->with($this->anything(), $this->callback(function ($exception) { - return 'Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "'.CsrfTokenBadge::class.'".' === $exception->getMessage(); - })); + $authenticator->expects($this->once())->method('onAuthenticationFailure')->with($this->anything(), $this->callback(fn ($exception) => 'Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "'.CsrfTokenBadge::class.'".' === $exception->getMessage())); - $manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class]); + $manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class], exposeSecurityErrors: ExposeSecurityLevel::None); $manager->authenticateRequest($this->request); } @@ -167,11 +183,13 @@ public function testAllRequiredBadgesPresent() $authenticator->expects($this->once())->method('onAuthenticationSuccess'); - $manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class]); + $manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class], exposeSecurityErrors: ExposeSecurityLevel::None); $manager->authenticateRequest($this->request); } /** + * @group legacy + * * @dataProvider provideEraseCredentialsData */ public function testEraseCredentials($eraseCredentials) @@ -179,14 +197,27 @@ public function testEraseCredentials($eraseCredentials) $authenticator = $this->createAuthenticator(); $this->request->attributes->set('_security_authenticators', [$authenticator]); - $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); - $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); + $token = new class extends AbstractToken { + public $erased = false; + + public function eraseCredentials(): void + { + $this->erased = true; + } + }; + + $authenticator->expects($this->any())->method('createToken')->willReturn($token); - $this->token->expects($eraseCredentials ? $this->once() : $this->never())->method('eraseCredentials'); + if ($eraseCredentials) { + $this->expectUserDeprecationMessage(\sprintf('Since symfony/security-http 7.3: Implementing "%s@anonymous::eraseCredentials()" is deprecated since Symfony 7.3; add the #[\Deprecated] attribute on the method to signal its either empty or that you moved the logic elsewhere, typically to the "__serialize()" method.', AbstractToken::class)); + } - $manager = $this->createManager([$authenticator], 'main', $eraseCredentials); + $manager = $this->createManager([$authenticator], 'main', $eraseCredentials, exposeSecurityErrors: ExposeSecurityLevel::None); $manager->authenticateRequest($this->request); + + $this->assertSame($eraseCredentials, $token->erased); } public static function provideEraseCredentialsData() @@ -200,7 +231,7 @@ public function testAuthenticateRequestCanModifyTokenFromEvent() $authenticator = $this->createAuthenticator(); $this->request->attributes->set('_security_authenticators', [$authenticator]); - $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); @@ -214,7 +245,7 @@ public function testAuthenticateRequestCanModifyTokenFromEvent() $this->tokenStorage->expects($this->once())->method('setToken')->with($this->identicalTo($modifiedToken)); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $this->assertNull($manager->authenticateRequest($this->request)); $this->assertTrue($listenerCalled, 'The AuthenticationTokenCreatedEvent listener is not called'); } @@ -222,13 +253,24 @@ public function testAuthenticateRequestCanModifyTokenFromEvent() public function testAuthenticateUser() { $authenticator = $this->createAuthenticator(); - $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); $authenticator->expects($this->any())->method('onAuthenticationSuccess')->willReturn($this->response); + $badge = new UserBadge('alex'); + + $authenticator + ->expects($this->any()) + ->method('createToken') + ->willReturnCallback(function (Passport $passport) use ($badge) { + $this->assertSame(['attr' => 'foo', 'attr2' => 'bar'], $passport->getAttributes()); + $this->assertSame([UserBadge::class => $badge], $passport->getBadges()); + + return $this->token; + }); + $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); - $manager = $this->createManager([$authenticator]); - $manager->authenticateUser($this->user, $authenticator, $this->request); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); + $manager->authenticateUser($this->user, $authenticator, $this->request, [$badge], ['attr' => 'foo', 'attr2' => 'bar']); } public function testAuthenticateUserCanModifyTokenFromEvent() @@ -247,7 +289,7 @@ public function testAuthenticateUserCanModifyTokenFromEvent() $this->tokenStorage->expects($this->once())->method('setToken')->with($this->identicalTo($modifiedToken)); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $manager->authenticateUser($this->user, $authenticator, $this->request); $this->assertTrue($listenerCalled, 'The AuthenticationTokenCreatedEvent listener is not called'); } @@ -258,7 +300,7 @@ public function testInteractiveAuthenticator() $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); $this->request->attributes->set('_security_authenticators', [$authenticator]); - $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); @@ -268,7 +310,7 @@ public function testInteractiveAuthenticator() ->with($this->anything(), $this->token, 'main') ->willReturn($this->response); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $response = $manager->authenticateRequest($this->request); $this->assertSame($this->response, $response); } @@ -279,8 +321,8 @@ public function testLegacyInteractiveAuthenticator() $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); $this->request->attributes->set('_security_authenticators', [$authenticator]); - $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); - $authenticator->expects($this->any())->method('createAuthenticatedToken')->willReturn($this->token); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); + $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); @@ -289,7 +331,7 @@ public function testLegacyInteractiveAuthenticator() ->with($this->anything(), $this->token, 'main') ->willReturn($this->response); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $response = $manager->authenticateRequest($this->request); $this->assertSame($this->response, $response); } @@ -304,12 +346,46 @@ public function testAuthenticateRequestHidesInvalidUserExceptions() $authenticator->expects($this->any()) ->method('onAuthenticationFailure') - ->with($this->equalTo($this->request), $this->callback(function ($e) use ($invalidUserException) { - return $e instanceof BadCredentialsException && $invalidUserException === $e->getPrevious(); - })) + ->with($this->equalTo($this->request), $this->callback(fn ($e) => $e instanceof BadCredentialsException && $invalidUserException === $e->getPrevious())) + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + public function testAuthenticateRequestShowsAccountStatusException() + { + $invalidUserException = new LockedException(); + $authenticator = $this->createMock(TestInteractiveAuthenticator::class); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willThrowException($invalidUserException); + + $authenticator->expects($this->any()) + ->method('onAuthenticationFailure') + ->with($this->equalTo($this->request), $this->callback(fn ($e) => $e === $invalidUserException)) + ->willReturn($this->response); + + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::AccountStatus); + $response = $manager->authenticateRequest($this->request); + $this->assertSame($this->response, $response); + } + + public function testAuthenticateRequestHidesInvalidAccountStatusException() + { + $invalidUserException = new LockedException(); + $authenticator = $this->createMock(TestInteractiveAuthenticator::class); + $this->request->attributes->set('_security_authenticators', [$authenticator]); + + $authenticator->expects($this->any())->method('authenticate')->willThrowException($invalidUserException); + + $authenticator->expects($this->any()) + ->method('onAuthenticationFailure') + ->with($this->equalTo($this->request), $this->callback(fn ($e) => $e instanceof BadCredentialsException && $invalidUserException === $e->getPrevious())) ->willReturn($this->response); - $manager = $this->createManager([$authenticator]); + $manager = $this->createManager([$authenticator], exposeSecurityErrors: ExposeSecurityLevel::None); $response = $manager->authenticateRequest($this->request); $this->assertSame($this->response, $response); } @@ -320,7 +396,7 @@ public function testLogsUseTheDecoratedAuthenticatorWhenItIsTraceable() $authenticator->expects($this->any())->method('isInteractive')->willReturn(true); $this->request->attributes->set('_security_authenticators', [new TraceableAuthenticator($authenticator)]); - $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); + $authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); $authenticator->expects($this->any())->method('createToken')->willReturn($this->token); $this->tokenStorage->expects($this->once())->method('setToken')->with($this->token); @@ -335,8 +411,8 @@ public function testLogsUseTheDecoratedAuthenticatorWhenItIsTraceable() ->with($this->anything(), $this->token, 'main') ->willReturn($this->response); - $logger = new class() extends AbstractLogger { - public $logContexts = []; + $logger = new class extends AbstractLogger { + public array $logContexts = []; public function log($level, $message, array $context = []): void { @@ -346,10 +422,10 @@ public function log($level, $message, array $context = []): void } }; - $manager = $this->createManager([$authenticator], 'main', true, [], $logger); + $manager = $this->createManager([$authenticator], 'main', false, [], $logger, exposeSecurityErrors: ExposeSecurityLevel::None); $response = $manager->authenticateRequest($this->request); $this->assertSame($this->response, $response); - $this->assertStringContainsString('Mock_TestInteractiveAuthenticator', $logger->logContexts[0]['authenticator']); + $this->assertStringContainsString($authenticator::class, $logger->logContexts[0]['authenticator']); } private function createAuthenticator(?bool $supports = true) @@ -365,9 +441,9 @@ private static function createDummySupportsAuthenticator(?bool $supports = true) return new DummySupportsAuthenticator($supports); } - private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = true, array $requiredBadges = [], ?LoggerInterface $logger = null) + private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = false, array $requiredBadges = [], ?LoggerInterface $logger = null, ExposeSecurityLevel $exposeSecurityErrors = ExposeSecurityLevel::AccountStatus) { - return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, $logger, $eraseCredentials, true, $requiredBadges); + return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, $logger, $eraseCredentials, $exposeSecurityErrors, $requiredBadges); } } diff --git a/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php b/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php index 2e01c7db..e29d62dc 100644 --- a/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php +++ b/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\Authentication; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\ParameterBag; @@ -20,27 +21,33 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler; use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\SecurityRequestAttributes; class DefaultAuthenticationFailureHandlerTest extends TestCase { - private $httpKernel; - private $httpUtils; - private $logger; - private $request; - private $session; - private $exception; + private MockObject&HttpKernelInterface $httpKernel; + private MockObject&HttpUtils $httpUtils; + private MockObject&LoggerInterface $logger; + private Request $request; + private Response $response; + private MockObject&SessionInterface $session; + private AuthenticationException $exception; protected function setUp(): void { + $this->response = new Response(); $this->httpKernel = $this->createMock(HttpKernelInterface::class); + $this->httpKernel->expects($this->any()) + ->method('handle')->willReturn($this->response); + $this->httpUtils = $this->createMock(HttpUtils::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(SessionInterface::class); $this->request = $this->createMock(Request::class); + $this->request->attributes = new ParameterBag(['_stateless' => false]); $this->request->expects($this->any())->method('getSession')->willReturn($this->session); $this->exception = $this->getMockBuilder(AuthenticationException::class)->onlyMethods(['getMessage'])->getMock(); } @@ -51,20 +58,15 @@ public function testForward() $subRequest = $this->getRequest(); $subRequest->attributes->expects($this->once()) - ->method('set')->with(Security::AUTHENTICATION_ERROR, $this->exception); + ->method('set')->with(SecurityRequestAttributes::AUTHENTICATION_ERROR, $this->exception); $this->httpUtils->expects($this->once()) ->method('createRequest')->with($this->request, '/login') ->willReturn($subRequest); - $response = new Response(); - $this->httpKernel->expects($this->once()) - ->method('handle')->with($subRequest, HttpKernelInterface::SUB_REQUEST) - ->willReturn($response); - $handler = new DefaultAuthenticationFailureHandler($this->httpKernel, $this->httpUtils, $options, $this->logger); $result = $handler->onAuthenticationFailure($this->request, $this->exception); - $this->assertSame($response, $result); + $this->assertSame($this->response, $result); } public function testRedirect() @@ -83,7 +85,18 @@ public function testRedirect() public function testExceptionIsPersistedInSession() { $this->session->expects($this->once()) - ->method('set')->with(Security::AUTHENTICATION_ERROR, $this->exception); + ->method('set')->with(SecurityRequestAttributes::AUTHENTICATION_ERROR, $this->exception); + + $handler = new DefaultAuthenticationFailureHandler($this->httpKernel, $this->httpUtils, [], $this->logger); + $handler->onAuthenticationFailure($this->request, $this->exception); + } + + public function testExceptionIsNotPersistedInSessionOnStatelessRequest() + { + $this->request->attributes = new ParameterBag(['_stateless' => true]); + + $this->session->expects($this->never()) + ->method('set')->with(SecurityRequestAttributes::AUTHENTICATION_ERROR, $this->exception); $handler = new DefaultAuthenticationFailureHandler($this->httpKernel, $this->httpUtils, [], $this->logger); $handler->onAuthenticationFailure($this->request, $this->exception); @@ -95,7 +108,7 @@ public function testExceptionIsPassedInRequestOnForward() $subRequest = $this->getRequest(); $subRequest->attributes->expects($this->once()) - ->method('set')->with(Security::AUTHENTICATION_ERROR, $this->exception); + ->method('set')->with(SecurityRequestAttributes::AUTHENTICATION_ERROR, $this->exception); $this->httpUtils->expects($this->once()) ->method('createRequest')->with($this->request, '/login') diff --git a/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php b/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php index 2d63821b..a9750223 100644 --- a/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php +++ b/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php @@ -56,6 +56,25 @@ public function testRequestRedirectionsWithTargetPathInSessions() $this->assertSame('http://localhost/admin/dashboard', $handler->onAuthenticationSuccess($requestWithSession, $token)->getTargetUrl()); } + public function testStatelessRequestRedirections() + { + $session = $this->createMock(SessionInterface::class); + $session->expects($this->never())->method('get')->with('_security.admin.target_path'); + $session->expects($this->never())->method('remove')->with('_security.admin.target_path'); + $statelessRequest = Request::create('/'); + $statelessRequest->setSession($session); + $statelessRequest->attributes->set('_stateless', true); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->any())->method('generate')->willReturn('http://localhost/login'); + $httpUtils = new HttpUtils($urlGenerator); + $token = $this->createMock(TokenInterface::class); + $handler = new DefaultAuthenticationSuccessHandler($httpUtils); + $handler->setFirewallName('admin'); + + $this->assertSame('http://localhost/', $handler->onAuthenticationSuccess($statelessRequest, $token)->getTargetUrl()); + } + public static function getRequestRedirections() { return [ diff --git a/Tests/Authenticator/AbstractAuthenticatorTest.php b/Tests/Authenticator/AbstractAuthenticatorTest.php index ce5186e1..77ca011f 100644 --- a/Tests/Authenticator/AbstractAuthenticatorTest.php +++ b/Tests/Authenticator/AbstractAuthenticatorTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -21,33 +20,17 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; class AbstractAuthenticatorTest extends TestCase { - use ExpectDeprecationTrait; - public function testCreateToken() { $authenticator = new ConcreteAuthenticator(); $this->assertInstanceOf( PostAuthenticationToken::class, - $authenticator->createToken(new SelfValidatingPassport(new UserBadge('dummy', function () { return new InMemoryUser('robin', 'hood'); })), 'dummy') - ); - } - - /** - * @group legacy - */ - public function testLegacyCreateAuthenticatedToken() - { - $authenticator = new ConcreteAuthenticator(); - $this->expectDeprecation('Since symfony/security-http 5.4: Method "Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator::createAuthenticatedToken()" is deprecated, use "Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator::createToken()" instead.'); - $this->assertInstanceOf( - PostAuthenticationToken::class, - $authenticator->createAuthenticatedToken(new SelfValidatingPassport(new UserBadge('dummy', function () { return new InMemoryUser('robin', 'hood'); })), 'dummy') + $authenticator->createToken(new SelfValidatingPassport(new UserBadge('dummy', fn () => new InMemoryUser('robin', 'hood'))), 'dummy') ); } } @@ -59,11 +42,6 @@ public function createToken(Passport $passport, string $firewallName): TokenInte return parent::createToken($passport, $firewallName); } - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - return parent::createAuthenticatedToken($passport, $firewallName); - } - public function supports(Request $request): ?bool { return null; diff --git a/Tests/Authenticator/AbstractLoginFormAuthenticatorTest.php b/Tests/Authenticator/AbstractLoginFormAuthenticatorTest.php index 40044604..c155ed98 100644 --- a/Tests/Authenticator/AbstractLoginFormAuthenticatorTest.php +++ b/Tests/Authenticator/AbstractLoginFormAuthenticatorTest.php @@ -11,13 +11,13 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; -use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; class AbstractLoginFormAuthenticatorTest extends TestCase @@ -98,7 +98,7 @@ public static function provideSupportsData(): iterable class ConcreteFormAuthenticator extends AbstractLoginFormAuthenticator { - private $loginUrl; + private string $loginUrl; public function __construct(string $loginUrl) { diff --git a/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php b/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php new file mode 100644 index 00000000..b69ef75f --- /dev/null +++ b/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; +use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class ChainedAccessTokenExtractorsTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + /** + * @dataProvider provideSupportData + */ + public function testSupport($request) + { + $this->setUpAuthenticator(); + + $this->assertNull($this->authenticator->supports($request)); + } + + public static function provideSupportData(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN'])]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN'])]; + } + + public function testAuthenticate() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator(); + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid(Request $request, string $errorMessage, string $exceptionType) + { + $this->setUpAuthenticator(); + + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->authenticator->authenticate($request); + } + + public static function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BAD']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT FOO']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer contains invalid characters such as whitespaces']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BearerVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + new ChainAccessTokenExtractor([ + new FormEncodedBodyExtractor(), + new QueryAccessTokenExtractor(), + new HeaderAccessTokenExtractor(), + ]), + $this->userProvider + ); + } +} diff --git a/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php b/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php new file mode 100644 index 00000000..5980fe92 --- /dev/null +++ b/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class FormEncodedBodyAccessTokenAuthenticatorTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + public function testSupport() + { + $this->setUpAuthenticator(); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->request->set('access_token', 'INVALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testSupportsWithCustomParameter() + { + $this->setUpAuthenticator('protection-token'); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->request->set('protection-token', 'INVALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testAuthenticate() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator(); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'], 'access_token=VALID_ACCESS_TOKEN'); + $request->request->set('access_token', 'VALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateWithCustomParameter() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator('protection-token'); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->request->set('protection-token', 'VALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid(Request $request, string $errorMessage, string $exceptionType) + { + $this->setUpAuthenticator(); + + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->authenticator->authenticate($request); + } + + public static function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + $request->setMethod(Request::METHOD_GET); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->setMethod(Request::METHOD_POST); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $request->setMethod(Request::METHOD_POST); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->setMethod(Request::METHOD_POST); + $request->request->set('foo', 'VALID_ACCESS_TOKEN'); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->setMethod(Request::METHOD_POST); + $request->request->set('access_token', 'INVALID_ACCESS_TOKEN'); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(string $parameter = 'access_token'): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + new FormEncodedBodyExtractor($parameter), + $this->userProvider + ); + } +} diff --git a/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php b/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php new file mode 100644 index 00000000..82fe159a --- /dev/null +++ b/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class HeaderAccessTokenAuthenticatorTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + /** + * @dataProvider provideSupportData + */ + public function testSupport($request) + { + $this->setUpAuthenticator(); + + $this->assertNull($this->authenticator->supports($request)); + } + + public static function provideSupportData(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN'])]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN'])]; + } + + /** + * @dataProvider provideSupportsWithCustomTokenTypeData + */ + public function testSupportsWithCustomTokenType($request, $result) + { + $this->setUpAuthenticator('Authorization', 'JWT'); + + $this->assertSame($result, $this->authenticator->supports($request)); + } + + public static function provideSupportsWithCustomTokenTypeData(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT VALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT INVALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']), false]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']), false]; + } + + /** + * @dataProvider provideSupportsWithCustomHeaderParameter + */ + public function testSupportsWithCustomHeaderParameter($request, $result) + { + $this->setUpAuthenticator('X-FOO'); + + $this->assertSame($result, $this->authenticator->supports($request)); + } + + public static function provideSupportsWithCustomHeaderParameter(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_X_FOO' => 'Bearer VALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_X_FOO' => 'Bearer INVALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']), false]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']), false]; + } + + public function testAuthenticate() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator(); + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateWithCustomTokenType() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator('Authorization', 'JWT'); + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT VALID_ACCESS_TOKEN']); + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid(Request $request, string $errorMessage, string $exceptionType) + { + $this->setUpAuthenticator(); + + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->authenticator->authenticate($request); + } + + public static function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BAD']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT FOO']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer contains invalid characters such as whitespaces']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BearerVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(string $headerParameter = 'Authorization', string $tokenType = 'Bearer'): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + new HeaderAccessTokenExtractor($headerParameter, $tokenType), + $this->userProvider + ); + } +} diff --git a/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php b/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php new file mode 100644 index 00000000..60a28e71 --- /dev/null +++ b/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class QueryAccessTokenAuthenticatorTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + public function testSupport() + { + $this->setUpAuthenticator(); + $request = new Request(); + $request->query->set('access_token', 'INVALID_ACCESS_TOKEN'); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testSupportsWithCustomParameter() + { + $this->setUpAuthenticator('protection-token'); + $request = new Request(); + $request->query->set('protection-token', 'INVALID_ACCESS_TOKEN'); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testAuthenticate() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator(); + $request = new Request(); + $request->query->set('access_token', 'VALID_ACCESS_TOKEN'); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateWithCustomParameter() + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', new UserBadge('foo')); + $this->setUpAuthenticator('protection-token'); + $request = new Request(); + $request->query->set('protection-token', 'VALID_ACCESS_TOKEN'); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid(Request $request, string $errorMessage, string $exceptionType) + { + $this->setUpAuthenticator(); + + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->authenticator->authenticate($request); + } + + public static function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->query->set('foo', 'VALID_ACCESS_TOKEN'); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->query->set('access_token', 123456789); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->query->set('access_token', 'INVALID_ACCESS_TOKEN'); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(string $parameter = 'access_token'): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + new QueryAccessTokenExtractor($parameter), + $this->userProvider + ); + } +} diff --git a/Tests/Authenticator/AccessTokenAuthenticatorTest.php b/Tests/Authenticator/AccessTokenAuthenticatorTest.php new file mode 100644 index 00000000..be6cc4ea --- /dev/null +++ b/Tests/Authenticator/AccessTokenAuthenticatorTest.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +class AccessTokenAuthenticatorTest extends TestCase +{ + private AccessTokenHandlerInterface $accessTokenHandler; + private AccessTokenExtractorInterface $accessTokenExtractor; + private InMemoryUserProvider $userProvider; + + protected function setUp(): void + { + $this->accessTokenHandler = $this->createMock(AccessTokenHandlerInterface::class); + $this->accessTokenExtractor = $this->createMock(AccessTokenExtractorInterface::class); + $this->userProvider = new InMemoryUserProvider(['test' => ['password' => 's$cr$t']]); + } + + public function testAuthenticateWithoutAccessToken() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn(null); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + ); + + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $authenticator->authenticate($request); + } + + public function testAuthenticateWithoutProvider() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('john', fn () => new InMemoryUser('john', null))); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('john', $passport->getUser()->getUserIdentifier()); + } + + public function testAuthenticateWithoutUserLoader() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('test')); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('test', $passport->getUser()->getUserIdentifier()); + } + + public function testAuthenticateWithUserLoader() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('john', fn () => new InMemoryUser('john', null))); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('john', $passport->getUser()->getUserIdentifier()); + } + + public function testAuthenticateWithFallbackUserLoader() + { + $request = Request::create('/test'); + + $this->accessTokenExtractor + ->expects($this->once()) + ->method('extractAccessToken') + ->with($request) + ->willReturn('test'); + $this->accessTokenHandler + ->expects($this->once()) + ->method('getUserBadgeFrom') + ->with('test') + ->willReturn(new UserBadge('test', new FallbackUserLoader(fn () => new InMemoryUser('john', null)))); + + $authenticator = new AccessTokenAuthenticator( + $this->accessTokenHandler, + $this->accessTokenExtractor, + $this->userProvider, + ); + + $passport = $authenticator->authenticate($request); + + $this->assertEquals('test', $passport->getUser()->getUserIdentifier()); + } + + /** + * @dataProvider provideAccessTokenHeaderRegex + */ + public function testAccessTokenHeaderRegex(string $input, ?string $expectedToken) + { + // Given + $extractor = new HeaderAccessTokenExtractor(); + $request = Request::create('/test', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => $input]); + + // When + $token = $extractor->extractAccessToken($request); + + // Then + $this->assertEquals($expectedToken, $token); + } + + public static function provideAccessTokenHeaderRegex(): array + { + return [ + ['Bearer token', 'token'], + ['Bearer mF_9.B5f-4.1JqM', 'mF_9.B5f-4.1JqM'], + ['Bearer d3JvbmdfcmVnZXhwX2V4bWFwbGU=', 'd3JvbmdfcmVnZXhwX2V4bWFwbGU='], + ['Bearer Not Valid', null], + ['Bearer (NotOK123)', null], + ]; + } +} diff --git a/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php b/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php index 8eac1ccf..7b3c4c09 100644 --- a/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php +++ b/Tests/Authenticator/Debug/TraceableAuthenticatorTest.php @@ -26,6 +26,11 @@ public function testGetInfo() $passport = new SelfValidatingPassport(new UserBadge('robin', function () {})); $authenticator = $this->createMock(AuthenticatorInterface::class); + $authenticator->expects($this->once()) + ->method('supports') + ->with($request) + ->willReturn(true); + $authenticator ->expects($this->once()) ->method('authenticate') @@ -33,7 +38,25 @@ public function testGetInfo() ->willReturn($passport); $traceable = new TraceableAuthenticator($authenticator); + $this->assertTrue($traceable->supports($request)); $this->assertSame($passport, $traceable->authenticate($request)); $this->assertSame($passport, $traceable->getInfo()['passport']); } + + public function testGetInfoWithoutAuth() + { + $request = new Request(); + + $authenticator = $this->createMock(AuthenticatorInterface::class); + $authenticator->expects($this->once()) + ->method('supports') + ->with($request) + ->willReturn(false); + + $traceable = new TraceableAuthenticator($authenticator); + $this->assertFalse($traceable->supports($request)); + $this->assertNull($traceable->getInfo()['passport']); + $this->assertIsArray($traceable->getInfo()['badges']); + $this->assertSame([], $traceable->getInfo()['badges']); + } } diff --git a/Tests/Authenticator/Fixtures/PasswordUpgraderProvider.php b/Tests/Authenticator/Fixtures/PasswordUpgraderProvider.php index 851ecca3..7e5ee7e4 100644 --- a/Tests/Authenticator/Fixtures/PasswordUpgraderProvider.php +++ b/Tests/Authenticator/Fixtures/PasswordUpgraderProvider.php @@ -12,11 +12,12 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator\Fixtures; use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; class PasswordUpgraderProvider extends InMemoryUserProvider implements PasswordUpgraderInterface { - public function upgradePassword($user, string $newHashedPassword): void + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { } } diff --git a/Tests/Authenticator/FormLoginAuthenticatorTest.php b/Tests/Authenticator/FormLoginAuthenticatorTest.php index d9595e09..2cab6f26 100644 --- a/Tests/Authenticator/FormLoginAuthenticatorTest.php +++ b/Tests/Authenticator/FormLoginAuthenticatorTest.php @@ -11,29 +11,29 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\InMemoryUserProvider; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Security\Http\Tests\Authenticator\Fixtures\PasswordUpgraderProvider; class FormLoginAuthenticatorTest extends TestCase { - private $userProvider; - private $successHandler; - private $failureHandler; - /** @var FormLoginAuthenticator */ - private $authenticator; + private InMemoryUserProvider $userProvider; + private MockObject&AuthenticationSuccessHandlerInterface $successHandler; + private MockObject&AuthenticationFailureHandlerInterface $failureHandler; + private FormLoginAuthenticator $authenticator; protected function setUp(): void { @@ -42,6 +42,30 @@ protected function setUp(): void $this->failureHandler = $this->createMock(AuthenticationFailureHandlerInterface::class); } + public function testHandleWhenUsernameEmpty() + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('The key "_username" must be a non-empty string.'); + + $request = Request::create('/login_check', 'POST', ['_username' => '', '_password' => 's$cr$t']); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(); + $this->authenticator->authenticate($request); + } + + public function testHandleWhenPasswordEmpty() + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('The key "_password" must be a non-empty string.'); + + $request = Request::create('/login_check', 'POST', ['_username' => 'foo', '_password' => '']); + $request->setSession($this->createSession()); + + $this->setUpAuthenticator(); + $this->authenticator->authenticate($request); + } + /** * @dataProvider provideUsernamesForLength */ @@ -51,7 +75,7 @@ public function testHandleWhenUsernameLength($username, $ok) $this->expectNotToPerformAssertions(); } else { $this->expectException(BadCredentialsException::class); - $this->expectExceptionMessage('Invalid username.'); + $this->expectExceptionMessage('Username too long.'); } $request = Request::create('/login_check', 'POST', ['_username' => $username, '_password' => 's$cr$t']); @@ -63,8 +87,8 @@ public function testHandleWhenUsernameLength($username, $ok) public static function provideUsernamesForLength() { - yield [str_repeat('x', Security::MAX_USERNAME_LENGTH + 1), false]; - yield [str_repeat('x', Security::MAX_USERNAME_LENGTH - 1), true]; + yield [str_repeat('x', UserBadge::MAX_USERNAME_LENGTH + 1), false]; + yield [str_repeat('x', UserBadge::MAX_USERNAME_LENGTH - 1), true]; } /** @@ -72,13 +96,14 @@ public static function provideUsernamesForLength() */ public function testHandleNonStringUsernameWithArray($postOnly) { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_username" must be a string, "array" given.'); - $request = Request::create('/login_check', 'POST', ['_username' => []]); $request->setSession($this->createSession()); $this->setUpAuthenticator(['post_only' => $postOnly]); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_username" must be a string, "array" given.'); + $this->authenticator->authenticate($request); } @@ -87,13 +112,14 @@ public function testHandleNonStringUsernameWithArray($postOnly) */ public function testHandleNonStringUsernameWithInt($postOnly) { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_username" must be a string, "integer" given.'); - $request = Request::create('/login_check', 'POST', ['_username' => 42]); $request->setSession($this->createSession()); $this->setUpAuthenticator(['post_only' => $postOnly]); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_username" must be a string, "integer" given.'); + $this->authenticator->authenticate($request); } @@ -102,13 +128,14 @@ public function testHandleNonStringUsernameWithInt($postOnly) */ public function testHandleNonStringUsernameWithObject($postOnly) { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_username" must be a string, "object" given.'); - $request = Request::create('/login_check', 'POST', ['_username' => new \stdClass()]); $request->setSession($this->createSession()); $this->setUpAuthenticator(['post_only' => $postOnly]); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_username" must be a string, "object" given.'); + $this->authenticator->authenticate($request); } @@ -132,13 +159,14 @@ public function testHandleNonStringUsernameWithToString($postOnly) */ public function testHandleNonStringPasswordWithArray(bool $postOnly) { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_password" must be a string, "array" given.'); - $request = Request::create('/login_check', 'POST', ['_username' => 'foo', '_password' => []]); $request->setSession($this->createSession()); $this->setUpAuthenticator(['post_only' => $postOnly]); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('The key "_password" must be a string, "array" given.'); + $this->authenticator->authenticate($request); } @@ -147,8 +175,8 @@ public function testHandleNonStringPasswordWithArray(bool $postOnly) */ public function testHandleNonStringPasswordWithToString(bool $postOnly) { - $passwordObject = new class() { - public function __toString() + $passwordObject = new class { + public function __toString(): string { return 's$cr$t'; } diff --git a/Tests/Authenticator/HttpBasicAuthenticatorTest.php b/Tests/Authenticator/HttpBasicAuthenticatorTest.php index b7b0cc01..67e19641 100644 --- a/Tests/Authenticator/HttpBasicAuthenticatorTest.php +++ b/Tests/Authenticator/HttpBasicAuthenticatorTest.php @@ -24,20 +24,19 @@ class HttpBasicAuthenticatorTest extends TestCase { - private $userProvider; - private $hasherFactory; - private $hasher; - private $authenticator; + private InMemoryUserProvider $userProvider; + private HttpBasicAuthenticator $authenticator; protected function setUp(): void { $this->userProvider = new InMemoryUserProvider(); - $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); - $this->hasher = $this->createMock(PasswordHasherInterface::class); - $this->hasherFactory + + $hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasherFactory ->expects($this->any()) ->method('getPasswordHasher') - ->willReturn($this->hasher); + ->willReturn($hasher); $this->authenticator = new HttpBasicAuthenticator('test', $this->userProvider); } diff --git a/Tests/Authenticator/InMemoryAccessTokenHandler.php b/Tests/Authenticator/InMemoryAccessTokenHandler.php new file mode 100644 index 00000000..03579f35 --- /dev/null +++ b/Tests/Authenticator/InMemoryAccessTokenHandler.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +class InMemoryAccessTokenHandler implements AccessTokenHandlerInterface +{ + /** + * @var array + */ + private array $accessTokens = []; + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + if (!\array_key_exists($accessToken, $this->accessTokens)) { + throw new BadCredentialsException('Invalid access token or invalid user.'); + } + + return $this->accessTokens[$accessToken]; + } + + public function remove(string $accessToken): self + { + unset($this->accessTokens[$accessToken]); + + return $this; + } + + public function add(string $accessToken, UserBadge $user): self + { + $this->accessTokens[$accessToken] = $user; + + return $this; + } +} diff --git a/Tests/Authenticator/JsonLoginAuthenticatorTest.php b/Tests/Authenticator/JsonLoginAuthenticatorTest.php index ae37976d..ced46daf 100644 --- a/Tests/Authenticator/JsonLoginAuthenticatorTest.php +++ b/Tests/Authenticator/JsonLoginAuthenticatorTest.php @@ -16,9 +16,9 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\InMemoryUserProvider; use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\Translation\Loader\ArrayLoader; @@ -26,9 +26,8 @@ class JsonLoginAuthenticatorTest extends TestCase { - private $userProvider; - /** @var JsonLoginAuthenticator */ - private $authenticator; + private InMemoryUserProvider $userProvider; + private JsonLoginAuthenticator $authenticator; protected function setUp(): void { @@ -94,13 +93,13 @@ public function testAuthenticateWithCustomPath() /** * @dataProvider provideInvalidAuthenticateData */ - public function testAuthenticateInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class) + public function testAuthenticateInvalid(Request $request, string $errorMessage, string $exceptionType = BadRequestHttpException::class) { + $this->setUpAuthenticator(); + $this->expectException($exceptionType); $this->expectExceptionMessage($errorMessage); - $this->setUpAuthenticator(); - $this->authenticator->authenticate($request); } @@ -116,14 +115,20 @@ public static function provideInvalidAuthenticateData() yield [$request, 'The key "password" must be provided']; $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": 1, "password": "foo"}'); - yield [$request, 'The key "username" must be a string.']; + yield [$request, 'The key "username" must be a non-empty string.']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "", "password": "foo"}'); + yield [$request, 'The key "username" must be a non-empty string.']; $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": 1}'); - yield [$request, 'The key "password" must be a string.']; + yield [$request, 'The key "password" must be a non-empty string.']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": ""}'); + yield [$request, 'The key "password" must be a non-empty string.']; - $username = str_repeat('x', Security::MAX_USERNAME_LENGTH + 1); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], sprintf('{"username": "%s", "password": 1}', $username)); - yield [$request, 'Invalid username.', BadCredentialsException::class]; + $username = str_repeat('x', UserBadge::MAX_USERNAME_LENGTH + 1); + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], \sprintf('{"username": "%s", "password": "foo"}', $username)); + yield [$request, 'Username too long.', BadCredentialsException::class]; } public function testAuthenticationFailureWithoutTranslator() @@ -151,7 +156,7 @@ public function testOnFailureReplacesMessageDataWithoutTranslator() { $this->setUpAuthenticator(); - $response = $this->authenticator->onAuthenticationFailure(new Request(), new class() extends AuthenticationException { + $response = $this->authenticator->onAuthenticationFailure(new Request(), new class extends AuthenticationException { public function getMessageData(): array { return ['%failed_attempts%' => 3]; diff --git a/Tests/Authenticator/LoginLinkAuthenticatorTest.php b/Tests/Authenticator/LoginLinkAuthenticatorTest.php index fb704d98..08af3a37 100644 --- a/Tests/Authenticator/LoginLinkAuthenticatorTest.php +++ b/Tests/Authenticator/LoginLinkAuthenticatorTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\User\UserInterface; @@ -27,11 +28,10 @@ class LoginLinkAuthenticatorTest extends TestCase { - private $loginLinkHandler; - private $successHandler; - private $failureHandler; - /** @var LoginLinkAuthenticator */ - private $authenticator; + private MockObject&LoginLinkHandlerInterface $loginLinkHandler; + private MockObject&AuthenticationSuccessHandlerInterface $successHandler; + private MockObject&AuthenticationFailureHandlerInterface $failureHandler; + private LoginLinkAuthenticator $authenticator; protected function setUp(): void { @@ -79,7 +79,6 @@ public function testSuccessfulAuthenticate() public function testUnsuccessfulAuthenticate() { - $this->expectException(InvalidLoginLinkAuthenticationException::class); $this->setUpAuthenticator(); $request = Request::create('/login/link/check?stuff=1&user=weaverryan'); @@ -89,13 +88,15 @@ public function testUnsuccessfulAuthenticate() ->willThrowException(new ExpiredLoginLinkException()); $passport = $this->authenticator->authenticate($request); + + $this->expectException(InvalidLoginLinkAuthenticationException::class); + // trigger the user loader to try to load the user $passport->getBadge(UserBadge::class)->getUser(); } public function testMissingUser() { - $this->expectException(InvalidLoginLinkAuthenticationException::class); $this->setUpAuthenticator(); $request = Request::create('/login/link/check?stuff=1'); @@ -103,6 +104,8 @@ public function testMissingUser() $this->loginLinkHandler->expects($this->never()) ->method('consumeLoginLink'); + $this->expectException(InvalidLoginLinkAuthenticationException::class); + $this->authenticator->authenticate($request); } diff --git a/Tests/Authenticator/Passport/Badge/UserBadgeTest.php b/Tests/Authenticator/Passport/Badge/UserBadgeTest.php index d8e4c4cb..f648d048 100644 --- a/Tests/Authenticator/Passport/Badge/UserBadgeTest.php +++ b/Tests/Authenticator/Passport/Badge/UserBadgeTest.php @@ -12,15 +12,61 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator\Passport\Badge; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\String\Slugger\AsciiSlugger; +use Symfony\Component\String\UnicodeString; + +use function Symfony\Component\String\u; class UserBadgeTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + public function testUserNotFound() { - $badge = new UserBadge('dummy', function () { return null; }); + $badge = new UserBadge('dummy', fn () => null); $this->expectException(UserNotFoundException::class); $badge->getUser(); } + + /** + * @group legacy + */ + public function testEmptyUserIdentifier() + { + $this->expectUserDeprecationMessage('Since symfony/security-http 7.2: Using an empty string as user identifier is deprecated and will throw an exception in Symfony 8.0.'); + // $this->expectException(BadCredentialsException::class) + new UserBadge('', fn () => null); + } + + /** + * @dataProvider provideUserIdentifierNormalizationData + */ + public function testUserIdentifierNormalization(string $identifier, string $expectedNormalizedIdentifier, callable $normalizer) + { + $badge = new UserBadge($identifier, fn () => null, identifierNormalizer: $normalizer); + + static::assertSame($expectedNormalizedIdentifier, $badge->getUserIdentifier()); + } + + public static function provideUserIdentifierNormalizationData(): iterable + { + $lowerAndNFKC = static fn (string $identifier) => u($identifier)->normalize(UnicodeString::NFKC)->lower()->toString(); + yield 'Simple lower conversion' => ['SmiTh', 'smith', $lowerAndNFKC]; + yield 'Normalize fi to fi. Other unicode characters are preserved (р, с, ѕ and а)' => ['рrinсeѕѕ.fionа', 'рrinсeѕѕ.fionа', $lowerAndNFKC]; + yield 'Greek characters' => ['ΝιΚόΛΑος', 'νικόλαος', $lowerAndNFKC]; + + $slugger = new AsciiSlugger('en'); + $asciiWithPrefix = static fn (string $identifier) => u($slugger->slug($identifier))->ascii()->lower()->prepend('USERID--')->toString(); + yield 'Username with prefix' => ['John Doe 1', 'USERID--john-doe-1', $asciiWithPrefix]; + + if (!\extension_loaded('intl')) { + return; + } + $upperAndAscii = fn (string $identifier) => u($identifier)->ascii()->upper()->toString(); + yield 'Greek to ASCII' => ['ΝιΚόΛΑος', 'NIKOLAOS', $upperAndAscii]; + yield 'Katakana to ASCII' => ['たなかそういち', 'TANAKASOUICHI', $upperAndAscii]; + } } diff --git a/Tests/Authenticator/RememberMeAuthenticatorTest.php b/Tests/Authenticator/RememberMeAuthenticatorTest.php index 55bdba53..fe262c22 100644 --- a/Tests/Authenticator/RememberMeAuthenticatorTest.php +++ b/Tests/Authenticator/RememberMeAuthenticatorTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; @@ -25,15 +26,15 @@ class RememberMeAuthenticatorTest extends TestCase { - private $rememberMeHandler; - private $tokenStorage; - private $authenticator; + private MockObject&RememberMeHandlerInterface $rememberMeHandler; + private TokenStorage $tokenStorage; + private RememberMeAuthenticator $authenticator; protected function setUp(): void { $this->rememberMeHandler = $this->createMock(RememberMeHandlerInterface::class); $this->tokenStorage = new TokenStorage(); - $this->authenticator = new RememberMeAuthenticator($this->rememberMeHandler, 's3cr3t', $this->tokenStorage, '_remember_me_cookie'); + $this->authenticator = new RememberMeAuthenticator($this->rememberMeHandler, $this->tokenStorage, '_remember_me_cookie'); } public function testSupportsTokenStorageWithToken() @@ -72,9 +73,7 @@ public function testAuthenticate() $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => $rememberMeDetails->toString()]); $passport = $this->authenticator->authenticate($request); - $this->rememberMeHandler->expects($this->once())->method('consumeRememberMeCookie')->with($this->callback(function ($arg) use ($rememberMeDetails) { - return $rememberMeDetails == $arg; - })); + $this->rememberMeHandler->expects($this->once())->method('consumeRememberMeCookie')->with($this->callback(fn ($arg) => $rememberMeDetails == $arg)); $passport->getUser(); // trigger the user loader } @@ -87,17 +86,19 @@ public function testAuthenticateWithoutToken() public function testAuthenticateWithoutOldToken() { + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => base64_encode('foo:bar')]); + $this->expectException(AuthenticationException::class); - $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => base64_encode('foo:bar')]); $this->authenticator->authenticate($request); } public function testAuthenticateWithTokenWithoutDelimiter() { + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'invalid']); + $this->expectException(AuthenticationException::class); - $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'invalid']); $this->authenticator->authenticate($request); } } diff --git a/Tests/Authenticator/RemoteUserAuthenticatorTest.php b/Tests/Authenticator/RemoteUserAuthenticatorTest.php index 5119f8ce..b94f9088 100644 --- a/Tests/Authenticator/RemoteUserAuthenticatorTest.php +++ b/Tests/Authenticator/RemoteUserAuthenticatorTest.php @@ -36,6 +36,7 @@ public function testSupportNoUser() $authenticator = new RemoteUserAuthenticator(new InMemoryUserProvider(), new TokenStorage(), 'main'); $this->assertFalse($authenticator->supports($this->createRequest([]))); + $this->assertFalse($authenticator->supports($this->createRequest(['REMOTE_USER' => '']))); } public function testSupportTokenStorageWithToken() diff --git a/Tests/Authenticator/X509AuthenticatorTest.php b/Tests/Authenticator/X509AuthenticatorTest.php index 7ee6aaa9..afc6335d 100644 --- a/Tests/Authenticator/X509AuthenticatorTest.php +++ b/Tests/Authenticator/X509AuthenticatorTest.php @@ -20,8 +20,8 @@ class X509AuthenticatorTest extends TestCase { - private $userProvider; - private $authenticator; + private InMemoryUserProvider $userProvider; + private X509Authenticator $authenticator; protected function setUp(): void { @@ -120,6 +120,35 @@ public function testAuthenticationCustomCredentialsKey() $this->assertEquals('cert@example.com', $passport->getUser()->getUserIdentifier()); } + /** + * @dataProvider provideServerVarsUserIdentifier + */ + public function testAuthenticationCustomCredentialsUserIdentifier($username, $credentials) + { + $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'SSL_CLIENT_S_DN_Email', 'SSL_CLIENT_S_DN', null, 'CN'); + + $request = $this->createRequest([ + 'SSL_CLIENT_S_DN' => $credentials, + ]); + $this->assertTrue($authenticator->supports($request)); + + $this->userProvider->createUser(new InMemoryUser($username, null)); + + $passport = $authenticator->authenticate($request); + $this->assertEquals($username, $passport->getUser()->getUserIdentifier()); + } + + public static function provideServerVarsUserIdentifier() + { + yield ['Sample certificate DN', 'CN=Sample certificate DN/emailAddress=cert@example.com']; + yield ['Sample certificate DN', 'CN=Sample certificate DN/emailAddress=cert+something@example.com']; + yield ['Sample certificate DN', 'CN=Sample certificate DN,emailAddress=cert@example.com']; + yield ['Sample certificate DN', 'CN=Sample certificate DN,emailAddress=cert+something@example.com']; + yield ['Sample certificate DN', 'emailAddress=cert+something@example.com,CN=Sample certificate DN']; + yield ['Firstname.Lastname', 'emailAddress=firstname.lastname@mycompany.co.uk,CN=Firstname.Lastname,OU=london,OU=company design and engineering,OU=Issuer London,OU=Roaming,OU=Interactive,OU=Users,OU=Standard,OU=Business,DC=england,DC=core,DC=company,DC=co,DC=uk']; + yield ['user1', 'C=FR, O=My Organization, CN=user1, emailAddress=user1@myorg.fr']; + } + private function createRequest(array $server) { return new Request([], [], [], [], [], $server); diff --git a/Tests/Controller/SecurityTokenValueResolverTest.php b/Tests/Controller/SecurityTokenValueResolverTest.php new file mode 100644 index 00000000..08c62310 --- /dev/null +++ b/Tests/Controller/SecurityTokenValueResolverTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Controller; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Http\Controller\SecurityTokenValueResolver; + +class SecurityTokenValueResolverTest extends TestCase +{ + public function testResolveSucceedsWithTokenInterface() + { + $user = new InMemoryUser('username', 'password'); + $token = new UsernamePasswordToken($user, 'provider'); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + + $resolver = new SecurityTokenValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', TokenInterface::class, false, false, null); + + $this->assertSame([$token], $resolver->resolve(Request::create('/'), $metadata)); + } + + public function testResolveSucceedsWithSubclassType() + { + $user = new InMemoryUser('username', 'password'); + $token = new UsernamePasswordToken($user, 'provider'); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + + $resolver = new SecurityTokenValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', UsernamePasswordToken::class, false, false, null, false); + + $this->assertSame([$token], $resolver->resolve(Request::create('/'), $metadata)); + } + + public function testResolveSucceedsWithNullableParamAndNoToken() + { + $tokenStorage = new TokenStorage(); + $resolver = new SecurityTokenValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', TokenInterface::class, false, false, null, true); + + $this->assertSame([], $resolver->resolve(Request::create('/'), $metadata)); + } + + public function testResolveThrowsUnauthenticatedWithNoToken() + { + $tokenStorage = new TokenStorage(); + $resolver = new SecurityTokenValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', UsernamePasswordToken::class, false, false, null, false); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('A security token is required but the token storage is empty.'); + + $resolver->resolve(Request::create('/'), $metadata); + } + + public function testIntegration() + { + $user = new InMemoryUser('username', 'password'); + $token = new UsernamePasswordToken($user, 'provider'); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + + $argumentResolver = new ArgumentResolver(null, [new SecurityTokenValueResolver($tokenStorage)]); + $this->assertSame([$token], $argumentResolver->getArguments(Request::create('/'), static function (TokenInterface $token) {})); + } + + public function testIntegrationNoToken() + { + $tokenStorage = new TokenStorage(); + + $argumentResolver = new ArgumentResolver(null, [new SecurityTokenValueResolver($tokenStorage), new DefaultValueResolver()]); + $this->assertSame([null], $argumentResolver->getArguments(Request::create('/'), static function (?TokenInterface $token) {})); + } + + public function testIntegrationNonNullablwWithNoToken() + { + $tokenStorage = new TokenStorage(); + + $argumentResolver = new ArgumentResolver(null, [new SecurityTokenValueResolver($tokenStorage), new DefaultValueResolver()]); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('A security token is required but the token storage is empty.'); + + $argumentResolver->getArguments(Request::create('/'), static function (TokenInterface $token) {}); + } +} diff --git a/Tests/Controller/UserValueResolverTest.php b/Tests/Controller/UserValueResolverTest.php index cf71fa02..6521c33f 100644 --- a/Tests/Controller/UserValueResolverTest.php +++ b/Tests/Controller/UserValueResolverTest.php @@ -16,8 +16,10 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\CurrentUser; @@ -25,38 +27,63 @@ class UserValueResolverTest extends TestCase { - public function testResolveNoToken() + public function testSupportsFailsWithNoType() { $tokenStorage = new TokenStorage(); + $resolver = new UserValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', null, false, false, null); + + $this->assertSame([], $resolver->resolve(Request::create('/'), $metadata)); + } + + public function testSupportsFailsWhenDefaultValAndNoUser() + { + $tokenStorage = new TokenStorage(); + $resolver = new UserValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', UserInterface::class, false, true, $default = new InMemoryUser('username', 'password')); + + $this->assertSame([$default], $resolver->resolve(Request::create('/'), $metadata)); + } + + public function testResolveSucceedsWithUserInterface() + { + $user = new InMemoryUser('username', 'password'); + $token = new UsernamePasswordToken($user, 'provider'); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + $resolver = new UserValueResolver($tokenStorage); $metadata = new ArgumentMetadata('foo', UserInterface::class, false, false, null); - $this->assertFalse($resolver->supports(Request::create('/'), $metadata)); + $this->assertSame([$user], $resolver->resolve(Request::create('/'), $metadata)); } - public function testResolveNoUser() + public function testResolveSucceedsWithSubclassType() { - $mock = $this->createMock(UserInterface::class); - $token = new UsernamePasswordToken(new InMemoryUser('username', 'password'), 'provider'); + $user = new InMemoryUser('username', 'password'); + $token = new UsernamePasswordToken($user, 'provider'); $tokenStorage = new TokenStorage(); $tokenStorage->setToken($token); $resolver = new UserValueResolver($tokenStorage); - $metadata = new ArgumentMetadata('foo', \get_class($mock), false, false, null); + $metadata = new ArgumentMetadata('foo', InMemoryUser::class, false, false, null, false, [new CurrentUser()]); - $this->assertFalse($resolver->supports(Request::create('/'), $metadata)); + $this->assertSame([$user], $resolver->resolve(Request::create('/'), $metadata)); } - public function testResolveWrongType() + public function testResolveSucceedsWithNullableParamAndNoUser() { + $token = new NullToken(); $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + $resolver = new UserValueResolver($tokenStorage); - $metadata = new ArgumentMetadata('foo', null, false, false, null); + $metadata = new ArgumentMetadata('foo', InMemoryUser::class, false, false, null, true, [new CurrentUser()]); - $this->assertFalse($resolver->supports(Request::create('/'), $metadata)); + $this->assertSame([null], $resolver->resolve(Request::create('/'), $metadata)); } - public function testResolve() + public function testResolveSucceedsWithNullableAttribute() { $user = new InMemoryUser('username', 'password'); $token = new UsernamePasswordToken($user, 'provider'); @@ -64,13 +91,12 @@ public function testResolve() $tokenStorage->setToken($token); $resolver = new UserValueResolver($tokenStorage); - $metadata = new ArgumentMetadata('foo', UserInterface::class, false, false, null); + $metadata = new ArgumentMetadata('foo', null, false, false, null, false, [new CurrentUser()]); - $this->assertTrue($resolver->supports(Request::create('/'), $metadata)); - $this->assertSame([$user], iterator_to_array($resolver->resolve(Request::create('/'), $metadata))); + $this->assertSame([$user], $resolver->resolve(Request::create('/'), $metadata)); } - public function testResolveWithAttribute() + public function testResolveSucceedsWithTypedAttribute() { $user = new InMemoryUser('username', 'password'); $token = new UsernamePasswordToken($user, 'provider'); @@ -78,21 +104,46 @@ public function testResolveWithAttribute() $tokenStorage->setToken($token); $resolver = new UserValueResolver($tokenStorage); - $metadata = $this->createMock(ArgumentMetadata::class); - $metadata = new ArgumentMetadata('foo', null, false, false, null, false, [new CurrentUser()]); + $metadata = new ArgumentMetadata('foo', InMemoryUser::class, false, false, null, false, [new CurrentUser()]); - $this->assertTrue($resolver->supports(Request::create('/'), $metadata)); - $this->assertSame([$user], iterator_to_array($resolver->resolve(Request::create('/'), $metadata))); + $this->assertSame([$user], $resolver->resolve(Request::create('/'), $metadata)); } - public function testResolveWithAttributeAndNoUser() + public function testResolveThrowsAccessDeniedWithWrongUserClass() { + $user = $this->createMock(UserInterface::class); + $token = new UsernamePasswordToken($user, 'provider'); $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); $resolver = new UserValueResolver($tokenStorage); - $metadata = new ArgumentMetadata('foo', null, false, false, null, false, [new CurrentUser()]); + $metadata = new ArgumentMetadata('foo', InMemoryUser::class, false, false, null, false, [new CurrentUser()]); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage(\sprintf('The logged-in user is an instance of "%s" but a user of type "Symfony\Component\Security\Core\User\InMemoryUser" is expected.', $user::class)); + $resolver->resolve(Request::create('/'), $metadata); + } + + public function testResolveThrowsAccessDeniedWithAttributeAndNoUser() + { + $tokenStorage = new TokenStorage(); + + $resolver = new UserValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', UserInterface::class, false, false, null, false, [new CurrentUser()]); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('There is no logged-in user to pass to $foo, make the argument nullable if you want to allow anonymous access to the action.'); + $resolver->resolve(Request::create('/'), $metadata); + } + + public function testResolveThrowsAcessDeniedWithNoToken() + { + $tokenStorage = new TokenStorage(); + $resolver = new UserValueResolver($tokenStorage); + $metadata = new ArgumentMetadata('foo', UserInterface::class, false, false, null); - $this->assertFalse($resolver->supports(Request::create('/'), $metadata)); + $this->expectException(AccessDeniedException::class); + $resolver->resolve(Request::create('/'), $metadata); } public function testIntegration() diff --git a/Tests/EntryPoint/BasicAuthenticationEntryPointTest.php b/Tests/EntryPoint/BasicAuthenticationEntryPointTest.php deleted file mode 100644 index 5bf71123..00000000 --- a/Tests/EntryPoint/BasicAuthenticationEntryPointTest.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\EntryPoint; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\EntryPoint\BasicAuthenticationEntryPoint; - -/** - * @group legacy - */ -class BasicAuthenticationEntryPointTest extends TestCase -{ - public function testStart() - { - $request = $this->createMock(Request::class); - - $authException = new AuthenticationException('The exception message'); - - $entryPoint = new BasicAuthenticationEntryPoint('TheRealmName'); - $response = $entryPoint->start($request, $authException); - - $this->assertEquals('Basic realm="TheRealmName"', $response->headers->get('WWW-Authenticate')); - $this->assertEquals(401, $response->getStatusCode()); - } - - public function testStartWithoutAuthException() - { - $request = $this->createMock(Request::class); - - $entryPoint = new BasicAuthenticationEntryPoint('TheRealmName'); - - $response = $entryPoint->start($request); - - $this->assertEquals('Basic realm="TheRealmName"', $response->headers->get('WWW-Authenticate')); - $this->assertEquals(401, $response->getStatusCode()); - } -} diff --git a/Tests/EntryPoint/FormAuthenticationEntryPointTest.php b/Tests/EntryPoint/FormAuthenticationEntryPointTest.php deleted file mode 100644 index 56520173..00000000 --- a/Tests/EntryPoint/FormAuthenticationEntryPointTest.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\EntryPoint; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Http\EntryPoint\FormAuthenticationEntryPoint; -use Symfony\Component\Security\Http\HttpUtils; - -/** - * @group legacy - */ -class FormAuthenticationEntryPointTest extends TestCase -{ - public function testStart() - { - $request = $this->createMock(Request::class); - $response = new RedirectResponse('/the/login/path'); - - $httpKernel = $this->createMock(HttpKernelInterface::class); - $httpUtils = $this->createMock(HttpUtils::class); - $httpUtils - ->expects($this->once()) - ->method('createRedirectResponse') - ->with($this->equalTo($request), $this->equalTo('/the/login/path')) - ->willReturn($response) - ; - - $entryPoint = new FormAuthenticationEntryPoint($httpKernel, $httpUtils, '/the/login/path', false); - - $this->assertEquals($response, $entryPoint->start($request)); - } - - public function testStartWithUseForward() - { - $request = $this->createMock(Request::class); - $subRequest = $this->createMock(Request::class); - $response = new Response('', 200); - - $httpUtils = $this->createMock(HttpUtils::class); - $httpUtils - ->expects($this->once()) - ->method('createRequest') - ->with($this->equalTo($request), $this->equalTo('/the/login/path')) - ->willReturn($subRequest) - ; - - $httpKernel = $this->createMock(HttpKernelInterface::class); - $httpKernel - ->expects($this->once()) - ->method('handle') - ->with($this->equalTo($subRequest), $this->equalTo(HttpKernelInterface::SUB_REQUEST)) - ->willReturn($response) - ; - - $entryPoint = new FormAuthenticationEntryPoint($httpKernel, $httpUtils, '/the/login/path', true); - - $entryPointResponse = $entryPoint->start($request); - - $this->assertEquals($response, $entryPointResponse); - $this->assertEquals(401, $entryPointResponse->getStatusCode()); - } -} diff --git a/Tests/EntryPoint/RetryAuthenticationEntryPointTest.php b/Tests/EntryPoint/RetryAuthenticationEntryPointTest.php deleted file mode 100644 index 9b9492d9..00000000 --- a/Tests/EntryPoint/RetryAuthenticationEntryPointTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\EntryPoint; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Http\EntryPoint\RetryAuthenticationEntryPoint; - -/** - * @group legacy - */ -class RetryAuthenticationEntryPointTest extends TestCase -{ - /** - * @dataProvider dataForStart - */ - public function testStart($httpPort, $httpsPort, $request, $expectedUrl) - { - $entryPoint = new RetryAuthenticationEntryPoint($httpPort, $httpsPort); - $response = $entryPoint->start($request); - - $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertEquals($expectedUrl, $response->headers->get('Location')); - } - - public static function dataForStart() - { - if (!class_exists(Request::class)) { - return [[]]; - } - - return [ - [ - 80, - 443, - Request::create('http://localhost/foo/bar?baz=bat'), - 'https://localhost/foo/bar?baz=bat', - ], - [ - 80, - 443, - Request::create('https://localhost/foo/bar?baz=bat'), - 'http://localhost/foo/bar?baz=bat', - ], - [ - 80, - 123, - Request::create('http://localhost/foo/bar?baz=bat'), - 'https://localhost:123/foo/bar?baz=bat', - ], - [ - 8080, - 443, - Request::create('https://localhost/foo/bar?baz=bat'), - 'http://localhost:8080/foo/bar?baz=bat', - ], - ]; - } -} diff --git a/Tests/EventListener/CheckCredentialsListenerTest.php b/Tests/EventListener/CheckCredentialsListenerTest.php index ea5b3dd9..1ade1bf0 100644 --- a/Tests/EventListener/CheckCredentialsListenerTest.php +++ b/Tests/EventListener/CheckCredentialsListenerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\PasswordHasherInterface; @@ -28,9 +29,9 @@ class CheckCredentialsListenerTest extends TestCase { - private $hasherFactory; - private $listener; - private $user; + private MockObject&PasswordHasherFactoryInterface $hasherFactory; + private CheckCredentialsListener $listener; + private InMemoryUser $user; protected function setUp(): void { @@ -42,7 +43,7 @@ protected function setUp(): void /** * @dataProvider providePasswords */ - public function testPasswordAuthenticated($password, $passwordValid, $result) + public function testPasswordAuthenticated(string $password, bool $passwordValid, bool $result) { $hasher = $this->createMock(PasswordHasherInterface::class); $hasher->expects($this->any())->method('verify')->with('password-hash', $password)->willReturn($passwordValid); @@ -55,7 +56,7 @@ public function testPasswordAuthenticated($password, $passwordValid, $result) } $credentials = new PasswordCredentials($password); - $this->listener->checkPassport($this->createEvent(new Passport(new UserBadge('wouter', function () { return $this->user; }), $credentials))); + $this->listener->checkPassport($this->createEvent(new Passport(new UserBadge('wouter', fn () => $this->user), $credentials))); if (true === $result) { $this->assertTrue($credentials->isResolved()); @@ -70,19 +71,22 @@ public static function providePasswords() public function testEmptyPassword() { + $this->hasherFactory + ->expects($this->never()) + ->method('getPasswordHasher'); + + $event = $this->createEvent(new Passport(new UserBadge('wouter', fn () => $this->user), new PasswordCredentials(''))); + $this->expectException(BadCredentialsException::class); $this->expectExceptionMessage('The presented password cannot be empty.'); - $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); - - $event = $this->createEvent(new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials(''))); $this->listener->checkPassport($event); } /** * @dataProvider provideCustomAuthenticatedResults */ - public function testCustomAuthenticated($result) + public function testCustomAuthenticated(bool $result) { $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); @@ -90,10 +94,8 @@ public function testCustomAuthenticated($result) $this->expectException(BadCredentialsException::class); } - $credentials = new CustomCredentials(function () use ($result) { - return $result; - }, ['password' => 'foo']); - $this->listener->checkPassport($this->createEvent(new Passport(new UserBadge('wouter', function () { return $this->user; }), $credentials))); + $credentials = new CustomCredentials(fn () => $result, ['password' => 'foo']); + $this->listener->checkPassport($this->createEvent(new Passport(new UserBadge('wouter', fn () => $this->user), $credentials))); if (true === $result) { $this->assertTrue($credentials->isResolved()); @@ -110,7 +112,7 @@ public function testNoCredentialsBadgeProvided() { $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); - $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); + $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('wouter', fn () => $this->user))); $this->listener->checkPassport($event); } @@ -121,7 +123,7 @@ public function testAddsPasswordUpgradeBadge() $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); - $passport = new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials('ThePa$$word')); + $passport = new Passport(new UserBadge('wouter', fn () => $this->user), new PasswordCredentials('ThePa$$word')); $this->listener->checkPassport($this->createEvent($passport)); $this->assertTrue($passport->hasBadge(PasswordUpgradeBadge::class)); @@ -137,7 +139,7 @@ public function testAddsNoPasswordUpgradeBadgeIfItAlreadyExists() $passport = $this->getMockBuilder(Passport::class) ->onlyMethods(['addBadge']) - ->setConstructorArgs([new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials('ThePa$$word'), [new PasswordUpgradeBadge('ThePa$$word')]]) + ->setConstructorArgs([new UserBadge('wouter', fn () => $this->user), new PasswordCredentials('ThePa$$word'), [new PasswordUpgradeBadge('ThePa$$word')]]) ->getMock(); $passport->expects($this->never())->method('addBadge')->with($this->isInstanceOf(PasswordUpgradeBadge::class)); @@ -154,7 +156,7 @@ public function testAddsNoPasswordUpgradeBadgeIfPasswordIsInvalid() $passport = $this->getMockBuilder(Passport::class) ->onlyMethods(['addBadge']) - ->setConstructorArgs([new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials('ThePa$$word'), [new PasswordUpgradeBadge('ThePa$$word')]]) + ->setConstructorArgs([new UserBadge('wouter', fn () => $this->user), new PasswordCredentials('ThePa$$word'), [new PasswordUpgradeBadge('ThePa$$word')]]) ->getMock(); $passport->expects($this->never())->method('addBadge')->with($this->isInstanceOf(PasswordUpgradeBadge::class)); diff --git a/Tests/EventListener/CheckRememberMeConditionsListenerTest.php b/Tests/EventListener/CheckRememberMeConditionsListenerTest.php index bb3ee9c4..218d09c1 100644 --- a/Tests/EventListener/CheckRememberMeConditionsListenerTest.php +++ b/Tests/EventListener/CheckRememberMeConditionsListenerTest.php @@ -15,31 +15,30 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\EventListener\CheckRememberMeConditionsListener; class CheckRememberMeConditionsListenerTest extends TestCase { - private $listener; - private $request; - private $response; + private CheckRememberMeConditionsListener $listener; + private Request $request; + private Response $response; protected function setUp(): void { $this->listener = new CheckRememberMeConditionsListener(); - $this->request = Request::create('/login'); - $this->request->request->set('_remember_me', true); - $this->response = new Response(); } - public function testSuccessfulLoginWithoutSupportingAuthenticator() + public function testSuccessfulHttpLoginWithoutSupportingAuthenticator() { + $this->createHttpRequest(); + $passport = $this->createPassport([]); $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); @@ -47,20 +46,43 @@ public function testSuccessfulLoginWithoutSupportingAuthenticator() $this->assertFalse($passport->hasBadge(RememberMeBadge::class)); } + public function testSuccessfulJsonLoginWithoutSupportingAuthenticator() + { + $this->createJsonRequest(); + + $passport = $this->createPassport([]); + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertFalse($passport->hasBadge(RememberMeBadge::class)); + } + public function testSuccessfulLoginWithoutRequestParameter() { $this->request = Request::create('/login'); - $passport = $this->createPassport(); + $this->response = new Response(); + $passport = $this->createPassport([new RememberMeBadge()]); $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); $this->assertFalse($passport->getBadge(RememberMeBadge::class)->isEnabled()); } - public function testSuccessfulLoginWhenRememberMeAlwaysIsTrue() + public function testSuccessfulHttpLoginWhenRememberMeAlwaysIsTrue() + { + $this->createHttpRequest(); + + $passport = $this->createPassport(); + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertTrue($passport->getBadge(RememberMeBadge::class)->isEnabled()); + } + + public function testSuccessfulJsonLoginWhenRememberMeAlwaysIsTrue() { + $this->createJsonRequest(); + $passport = $this->createPassport(); - $listener = new CheckRememberMeConditionsListener(['always_remember_me' => true]); $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); @@ -70,8 +92,10 @@ public function testSuccessfulLoginWhenRememberMeAlwaysIsTrue() /** * @dataProvider provideRememberMeOptInValues */ - public function testSuccessfulLoginWithOptInRequestParameter($optInValue) + public function testSuccessfulHttpLoginWithOptInRequestParameter($optInValue) { + $this->createHttpRequest(); + $this->request->request->set('_remember_me', $optInValue); $passport = $this->createPassport(); @@ -80,6 +104,20 @@ public function testSuccessfulLoginWithOptInRequestParameter($optInValue) $this->assertTrue($passport->getBadge(RememberMeBadge::class)->isEnabled()); } + /** + * @dataProvider provideRememberMeOptInValues + */ + public function testSuccessfulJsonLoginWithOptInRequestParameter($optInValue) + { + $this->createJsonRequest(['_remember_me' => $optInValue]); + + $passport = $this->createPassport([new RememberMeBadge(['_remember_me' => $optInValue])]); + + $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); + + $this->assertTrue($passport->getBadge(RememberMeBadge::class)->isEnabled()); + } + public static function provideRememberMeOptInValues() { yield ['true']; @@ -89,13 +127,27 @@ public static function provideRememberMeOptInValues() yield [true]; } - private function createLoginSuccessfulEvent(PassportInterface $passport) + private function createHttpRequest(): void + { + $this->request = Request::create('/login'); + $this->request->request->set('_remember_me', true); + $this->response = new Response(); + } + + private function createJsonRequest(array $content = ['_remember_me' => true]): void + { + $this->request = Request::create('/login', 'POST', [], [], [], [], json_encode($content)); + $this->request->headers->add(['Content-Type' => 'application/json']); + $this->response = new Response(); + } + + private function createLoginSuccessfulEvent(Passport $passport) { return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->request, $this->response, 'main_firewall'); } private function createPassport(?array $badges = null) { - return new SelfValidatingPassport(new UserBadge('test', function ($username) { return new User($username, null); }), $badges ?? [new RememberMeBadge()]); + return new SelfValidatingPassport(new UserBadge('test', fn ($username) => new InMemoryUser($username, null)), $badges ?? [new RememberMeBadge(['_remember_me' => true])]); } } diff --git a/Tests/EventListener/ClearSiteDataLogoutListenerTest.php b/Tests/EventListener/ClearSiteDataLogoutListenerTest.php new file mode 100644 index 00000000..c295502d --- /dev/null +++ b/Tests/EventListener/ClearSiteDataLogoutListenerTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\EventListener\ClearSiteDataLogoutListener; + +class ClearSiteDataLogoutListenerTest extends TestCase +{ + /** + * @dataProvider provideClearSiteDataConfig + */ + public function testLogout(array $clearSiteDataConfig, string $expectedHeader) + { + $response = new Response(); + $event = new LogoutEvent(new Request(), null); + $event->setResponse($response); + + $listener = new ClearSiteDataLogoutListener($clearSiteDataConfig); + + $headerCountBefore = $response->headers->count(); + + $listener->onLogout($event); + + $this->assertEquals(++$headerCountBefore, $response->headers->count()); + + $this->assertNotNull($response->headers->get('Clear-Site-Data')); + $this->assertEquals($expectedHeader, $response->headers->get('Clear-Site-Data')); + } + + public static function provideClearSiteDataConfig(): iterable + { + yield [['*'], '"*"']; + yield [['cache', 'cookies', 'storage', 'executionContexts'], '"cache", "cookies", "storage", "executionContexts"']; + } +} diff --git a/Tests/EventListener/CookieClearingLogoutListenerTest.php b/Tests/EventListener/CookieClearingLogoutListenerTest.php index f4c0e3d8..8f7d4660 100644 --- a/Tests/EventListener/CookieClearingLogoutListenerTest.php +++ b/Tests/EventListener/CookieClearingLogoutListenerTest.php @@ -27,7 +27,7 @@ public function testLogout() $event = new LogoutEvent(new Request(), null); $event->setResponse($response); - $listener = new CookieClearingLogoutListener(['foo' => ['path' => '/foo', 'domain' => 'foo.foo', 'secure' => true, 'samesite' => Cookie::SAMESITE_STRICT], 'foo2' => ['path' => null, 'domain' => null]]); + $listener = new CookieClearingLogoutListener(['foo' => ['path' => '/foo', 'domain' => 'foo.foo', 'secure' => true, 'samesite' => Cookie::SAMESITE_STRICT, 'partitioned' => true], 'foo2' => ['path' => null, 'domain' => null]]); $cookies = $response->headers->getCookies(); $this->assertCount(0, $cookies); @@ -43,6 +43,9 @@ public function testLogout() $this->assertEquals('foo.foo', $cookie->getDomain()); $this->assertEquals(Cookie::SAMESITE_STRICT, $cookie->getSameSite()); $this->assertTrue($cookie->isSecure()); + if (self::doesResponseHeaderBagClearChipsCookies()) { + $this->assertTrue($cookie->isPartitioned()); + } $this->assertTrue($cookie->isCleared()); $cookie = $cookies['']['/']['foo2']; @@ -51,6 +54,20 @@ public function testLogout() $this->assertNull($cookie->getDomain()); $this->assertNull($cookie->getSameSite()); $this->assertFalse($cookie->isSecure()); + if (self::doesResponseHeaderBagClearChipsCookies()) { + $this->assertFalse($cookie->isPartitioned()); + } $this->assertTrue($cookie->isCleared()); } + + /** + * Checks if the patch from https://github.com/symfony/symfony/pull/53703 is available. + */ + private static function doesResponseHeaderBagClearChipsCookies(): bool + { + $bag = new ResponseHeaderBag(); + $bag->clearCookie('foo', '/', null, false, true, null, true); + + return $bag->getCookies()[0]->isPartitioned(); + } } diff --git a/Tests/EventListener/CsrfProtectionListenerTest.php b/Tests/EventListener/CsrfProtectionListenerTest.php index 358475a2..cdb24892 100644 --- a/Tests/EventListener/CsrfProtectionListenerTest.php +++ b/Tests/EventListener/CsrfProtectionListenerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -25,8 +26,8 @@ class CsrfProtectionListenerTest extends TestCase { - private $csrfTokenManager; - private $listener; + private MockObject&CsrfTokenManagerInterface $csrfTokenManager; + private CsrfProtectionListener $listener; protected function setUp(): void { @@ -49,23 +50,25 @@ public function testValidCsrfToken() ->with(new CsrfToken('authenticator_token_id', 'abc123')) ->willReturn(true); - $event = $this->createEvent($this->createPassport(new CsrfTokenBadge('authenticator_token_id', 'abc123'))); + $badge = new CsrfTokenBadge('authenticator_token_id', 'abc123'); + $event = $this->createEvent($this->createPassport($badge)); $this->listener->checkPassport($event); - $this->expectNotToPerformAssertions(); + $this->assertTrue($badge->isResolved()); } public function testInvalidCsrfToken() { - $this->expectException(InvalidCsrfTokenException::class); - $this->expectExceptionMessage('Invalid CSRF token.'); - $this->csrfTokenManager->expects($this->any()) ->method('isTokenValid') ->with(new CsrfToken('authenticator_token_id', 'abc123')) ->willReturn(false); $event = $this->createEvent($this->createPassport(new CsrfTokenBadge('authenticator_token_id', 'abc123'))); + + $this->expectException(InvalidCsrfTokenException::class); + $this->expectExceptionMessage('Invalid CSRF token.'); + $this->listener->checkPassport($event); } @@ -76,7 +79,7 @@ private function createEvent($passport) private function createPassport(?CsrfTokenBadge $badge) { - $passport = new SelfValidatingPassport(new UserBadge('wouter', function ($username) { return new InMemoryUser($username, 'pass'); })); + $passport = new SelfValidatingPassport(new UserBadge('wouter', fn ($username) => new InMemoryUser($username, 'pass'))); if ($badge) { $passport->addBadge($badge); } diff --git a/Tests/EventListener/CsrfTokenClearingLogoutListenerTest.php b/Tests/EventListener/CsrfTokenClearingLogoutListenerTest.php new file mode 100644 index 00000000..405c7ae0 --- /dev/null +++ b/Tests/EventListener/CsrfTokenClearingLogoutListenerTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage; +use Symfony\Component\Security\Http\Event\LogoutEvent; +use Symfony\Component\Security\Http\EventListener\CsrfTokenClearingLogoutListener; + +class CsrfTokenClearingLogoutListenerTest extends TestCase +{ + public function testSkipsClearingSessionTokenStorageOnStatelessRequest() + { + try { + (new CsrfTokenClearingLogoutListener( + new SessionTokenStorage(new RequestStack()) + ))->onLogout(new LogoutEvent(new Request(), null)); + } catch (SessionNotFoundException) { + $this->fail('clear() must not be called if the request is not associated with a session instance'); + } + + $this->addToAssertionCount(1); + } +} diff --git a/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php b/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php new file mode 100644 index 00000000..6ce136ff --- /dev/null +++ b/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php @@ -0,0 +1,346 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Http\EventListener\IsCsrfTokenValidAttributeListener; +use Symfony\Component\Security\Http\Tests\Fixtures\IsCsrfTokenValidAttributeController; +use Symfony\Component\Security\Http\Tests\Fixtures\IsCsrfTokenValidAttributeMethodsController; + +class IsCsrfTokenValidAttributeListenerTest extends TestCase +{ + public function testIsCsrfTokenValidCalledCorrectlyOnInvokableClass() + { + $request = new Request(request: ['_token' => 'bar']); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + new IsCsrfTokenValidAttributeController(), + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testNothingHappensWithNoConfig() + { + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->never()) + ->method('isTokenValid'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'noAttribute'], + [], + new Request(), + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectly() + { + $request = new Request(request: ['_token' => 'bar']); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withDefaultTokenKey'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyInPayload() + { + $request = new Request(server: ['headers' => ['content-type' => 'application/json']], content: json_encode(['_token' => 'bar'])); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withDefaultTokenKey'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyWithCustomExpressionId() + { + $request = new Request(query: ['id' => '123'], request: ['_token' => 'bar']); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo_123', 'bar')) + ->willReturn(true); + + $expressionLanguage = $this->createMock(ExpressionLanguage::class); + $expressionLanguage->expects($this->once()) + ->method('evaluate') + ->with(new Expression('"foo_" ~ args.id'), [ + 'args' => ['id' => '123'], + 'request' => $request, + ]) + ->willReturn('foo_123'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withCustomExpressionId'], + ['123'], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager, $expressionLanguage); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenKey() + { + $request = new Request(request: ['my_token_key' => 'bar']); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withCustomTokenKey'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKey() + { + $request = new Request(request: ['_token' => 'bar']); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', '')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withInvalidTokenKey'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testExceptionWhenInvalidToken() + { + $this->expectException(InvalidCsrfTokenException::class); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->withAnyParameters() + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withDefaultTokenKey'], + [], + new Request(), + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyWithDeleteMethod() + { + $request = new Request(request: ['_token' => 'bar']); + $request->setMethod('DELETE'); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withDeleteMethod'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidIgnoredWithNonMatchingMethod() + { + $request = new Request(request: ['_token' => 'bar']); + $request->setMethod('POST'); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->never()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withDeleteMethod'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyWithGetOrPostMethodWithGetMethod() + { + $request = new Request(request: ['_token' => 'bar']); + $request->setMethod('GET'); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withGetOrPostMethod'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidNoIgnoredWithGetOrPostMethodWithPutMethod() + { + $request = new Request(request: ['_token' => 'bar']); + $request->setMethod('PUT'); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->never()) + ->method('isTokenValid') + ->with(new CsrfToken('foo', 'bar')); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withGetOrPostMethod'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKeyAndPostMethod() + { + $this->expectException(InvalidCsrfTokenException::class); + + $request = new Request(request: ['_token' => 'bar']); + $request->setMethod('POST'); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->withAnyParameters() + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withPostMethodAndInvalidTokenKey'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } + + public function testIsCsrfTokenValidIgnoredWithInvalidTokenKeyAndUnavailableMethod() + { + $request = new Request(request: ['_token' => 'bar']); + $request->setMethod('PUT'); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->never()) + ->method('isTokenValid') + ->withAnyParameters(); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withPostMethodAndInvalidTokenKey'], + [], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager); + $listener->onKernelControllerArguments($event); + } +} diff --git a/Tests/EventListener/IsGrantedAttributeListenerTest.php b/Tests/EventListener/IsGrantedAttributeListenerTest.php new file mode 100644 index 00000000..d34b31f2 --- /dev/null +++ b/Tests/EventListener/IsGrantedAttributeListenerTest.php @@ -0,0 +1,458 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; +use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter; +use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\Voter; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; +use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeController; +use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsController; + +class IsGrantedAttributeListenerTest extends TestCase +{ + public function testAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->exactly(2)) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeController(), 'foo'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeController(), 'bar'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testNothingHappensWithNoConfig() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->never()) + ->method('isGranted'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'noAttribute'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedCalledCorrectly() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('ROLE_ADMIN') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'admin'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with('ROLE_ADMIN', 'arg2Value') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withSubject'], + ['arg1Value', 'arg2Value'], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArgumentsWithArray() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with('ROLE_ADMIN', [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => 'arg2Value', + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withSubjectArray'], + ['arg1Value', 'arg2Value'], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedNullSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('ROLE_ADMIN', null) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withSubject'], + ['arg1Value', null], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedArrayWithNullValueSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('ROLE_ADMIN', [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => null, + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withSubjectArray'], + ['arg1Value', null], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testExceptionWhenMissingSubjectAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withMissingSubject'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(\RuntimeException::class); + + $listener->onKernelControllerArguments($event); + } + + /** + * @dataProvider getAccessDeniedMessageTests + */ + public function testAccessDeniedMessages(string|Expression $attribute, string|array|null $subject, string $method, int $numOfArguments, string $expectedMessage) + { + $authChecker = new AuthorizationChecker(new TokenStorage(), new AccessDecisionManager((function () use (&$authChecker) { + yield new ExpressionVoter(new ExpressionLanguage(), null, $authChecker); + yield new RoleVoter(); + yield new class extends Voter { + protected function supports(string $attribute, mixed $subject): bool + { + return 'POST_VIEW' === $attribute; + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $vote?->addReason('Because I can 😈.'); + + return false; + } + }; + })())); + + $expressionLanguage = $this->createMock(ExpressionLanguage::class); + $expressionLanguage->expects($this->any()) + ->method('evaluate') + ->willReturn('bar'); + + // avoid the error of the subject not being found in the request attributes + $arguments = array_fill(0, $numOfArguments, 'bar'); + + $listener = new IsGrantedAttributeListener($authChecker, $expressionLanguage); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), $method], + $arguments, + new Request(), + null + ); + + try { + $listener->onKernelControllerArguments($event); + $this->fail(); + } catch (AccessDeniedException $e) { + $this->assertSame($expectedMessage, $e->getMessage()); + $this->assertEquals([$attribute], $e->getAttributes()); + if (null !== $subject) { + $this->assertSame($subject, $e->getSubject()); + } else { + $this->assertNull($e->getSubject()); + } + } + } + + public static function getAccessDeniedMessageTests() + { + yield ['ROLE_ADMIN', null, 'admin', 0, 'Access Denied. The user doesn\'t have ROLE_ADMIN.']; + yield ['ROLE_ADMIN', 'bar', 'withSubject', 2, 'Access Denied. The user doesn\'t have ROLE_ADMIN.']; + yield ['ROLE_ADMIN', ['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied. The user doesn\'t have ROLE_ADMIN.']; + yield [new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), 'bar', 'withExpressionInAttribute', 1, 'Access Denied. Because I can 😈. Expression ("ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)) is false.']; + yield [new Expression('user === subject'), 'bar', 'withExpressionInSubject', 1, 'Access Denied. Expression (user === subject) is false.']; + yield [new Expression('user === subject["author"]'), ['author' => 'bar', 'alias' => 'bar'], 'withNestedExpressionInSubject', 2, 'Access Denied. Expression (user === subject["author"]) is false.']; + } + + public function testNotFoundHttpException() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'notFound'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Not found'); + + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithExpressionInAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with(new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), 'postVal') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withExpressionInAttribute'], + ['postVal'], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithExpressionInSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with(new Expression('user === subject'), 'author') + ->willReturn(true); + + $expressionLanguage = $this->createMock(ExpressionLanguage::class); + $expressionLanguage->expects($this->once()) + ->method('evaluate') + ->with(new Expression('args["post"].getAuthor()'), [ + 'args' => ['post' => 'postVal'], + 'request' => $request, + ]) + ->willReturn('author'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withExpressionInSubject'], + ['postVal'], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker, $expressionLanguage); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithNestedExpressionInSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with(new Expression('user === subject["author"]'), ['author' => 'author', 'alias' => 'arg2Val']) + ->willReturn(true); + + $expressionLanguage = $this->createMock(ExpressionLanguage::class); + $expressionLanguage->expects($this->once()) + ->method('evaluate') + ->with(new Expression('args["post"].getAuthor()'), [ + 'args' => ['post' => 'postVal', 'arg2Name' => 'arg2Val'], + 'request' => $request, + ]) + ->willReturn('author'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withNestedExpressionInSubject'], + ['postVal', 'arg2Val'], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker, $expressionLanguage); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithRequestAsSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('SOME_VOTER', $request) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'withRequestAsSubject'], + [], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker, new ExpressionLanguage()); + $listener->onKernelControllerArguments($event); + } + + public function testHttpExceptionWithExceptionCode() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'exceptionCodeInHttpException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + $listener->onKernelControllerArguments($event); + } + + public function testAccessDeniedExceptionWithExceptionCode() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsController(), 'exceptionCodeInAccessDeniedException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + $listener->onKernelControllerArguments($event); + } +} diff --git a/Tests/EventListener/IsGrantedAttributeWithClosureListenerTest.php b/Tests/EventListener/IsGrantedAttributeWithClosureListenerTest.php new file mode 100644 index 00000000..2ea375ae --- /dev/null +++ b/Tests/EventListener/IsGrantedAttributeWithClosureListenerTest.php @@ -0,0 +1,374 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; +use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController; +use Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeWithClosureController; + +/** + * @requires PHP 8.5 + */ +class IsGrantedAttributeWithClosureListenerTest extends TestCase +{ + public function testAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->exactly(2)) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeWithClosureController(), 'foo'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeWithClosureController(), 'bar'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testNothingHappensWithNoConfig() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->never()) + ->method('isGranted'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'noAttribute'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedCalledCorrectly() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), null) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'admin'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with($this->isInstanceOf(\Closure::class), 'arg2Value') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubject'], + ['arg1Value', 'arg2Value'], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArgumentsWithArray() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with($this->isInstanceOf(\Closure::class), [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => 'arg2Value', + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubjectArray'], + ['arg1Value', 'arg2Value'], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedNullSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), null) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubject'], + ['arg1Value', null], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedArrayWithNullValueSubjectFromArguments() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => null, + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withSubjectArray'], + ['arg1Value', null], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testExceptionWhenMissingSubjectAttribute() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withMissingSubject'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(\RuntimeException::class); + + $listener->onKernelControllerArguments($event); + } + + /** + * @dataProvider getAccessDeniedMessageTests + */ + public function testAccessDeniedMessages(string|array|null $subject, string $method, int $numOfArguments, string $expectedMessage) + { + $authChecker = new AuthorizationChecker(new TokenStorage(), new AccessDecisionManager((function () use (&$authChecker) { + yield new ClosureVoter($authChecker); + })())); + + // avoid the error of the subject not being found in the request attributes + $arguments = array_fill(0, $numOfArguments, 'bar'); + $listener = new IsGrantedAttributeListener($authChecker); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), $method], + $arguments, + new Request(), + null + ); + + try { + $listener->onKernelControllerArguments($event); + $this->fail(); + } catch (AccessDeniedException $e) { + $this->assertSame($expectedMessage, $e->getMessage()); + $this->assertInstanceOf(\Closure::class, $e->getAttributes()[0]); + if (null !== $subject) { + $this->assertSame($subject, $e->getSubject()); + } else { + $this->assertNull($e->getSubject()); + } + } + } + + public static function getAccessDeniedMessageTests() + { + yield [null, 'admin', 0, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::admin():23} returned false.']; + yield ['bar', 'withSubject', 2, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withSubject():30} returned false.']; + yield [['arg1Name' => 'bar', 'arg2Name' => 'bar'], 'withSubjectArray', 2, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withSubjectArray():37} returned false.']; + yield ['bar', 'withClosureAsSubject', 1, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withClosureAsSubject():73} returned false.']; + yield [['author' => 'bar', 'alias' => 'bar'], 'withNestArgsInSubject', 2, 'Access Denied. Closure {closure:Symfony\Component\Security\Http\Tests\Fixtures\IsGrantedAttributeMethodsWithClosureController::withNestArgsInSubject():85} returned false.']; + } + + public function testNotFoundHttpException() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'notFound'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Not found'); + + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithClosureAsSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), 'postVal') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withClosureAsSubject'], + ['postVal'], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedWithNestedExpressionInSubject() + { + $request = new Request(); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with($this->isInstanceOf(\Closure::class), ['author' => 'postVal', 'alias' => 'bar']) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'withNestArgsInSubject'], + ['postVal', 'bar'], + $request, + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testHttpExceptionWithExceptionCode() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'exceptionCodeInHttpException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + $listener->onKernelControllerArguments($event); + } + + public function testAccessDeniedExceptionWithExceptionCode() + { + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsGrantedAttributeMethodsWithClosureController(), 'exceptionCodeInAccessDeniedException'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($authChecker); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('Exception Code'); + $this->expectExceptionCode(10010); + + $listener->onKernelControllerArguments($event); + } +} diff --git a/Tests/EventListener/LoginThrottlingListenerTest.php b/Tests/EventListener/LoginThrottlingListenerTest.php index c3bdf29e..450d1513 100644 --- a/Tests/EventListener/LoginThrottlingListenerTest.php +++ b/Tests/EventListener/LoginThrottlingListenerTest.php @@ -16,20 +16,20 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\RateLimiter\Storage\InMemoryStorage; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\CheckPassportEvent; -use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter; class LoginThrottlingListenerTest extends TestCase { - private $requestStack; - private $listener; + private RequestStack $requestStack; + private LoginThrottlingListener $listener; protected function setUp(): void { @@ -47,7 +47,7 @@ protected function setUp(): void 'limit' => 6, 'interval' => '1 minute', ], new InMemoryStorage()); - $limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter); + $limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter, '$3cre7'); $this->listener = new LoginThrottlingListener($this->requestStack, $limiter); } @@ -61,12 +61,7 @@ public function testPreventsLoginWhenOverLocalThreshold() for ($i = 0; $i < 3; ++$i) { $this->listener->checkPassport($this->createCheckPassportEvent($passport)); - } - - $this->listener->onSuccessfulLogin($this->createLoginSuccessfulEvent($passport)); - - for ($i = 0; $i < 3; ++$i) { - $this->listener->checkPassport($this->createCheckPassportEvent($passport)); + $this->listener->onFailedLogin($this->createLoginFailedEvent($passport)); } $this->expectException(TooManyLoginAttemptsAuthenticationException::class); @@ -82,6 +77,7 @@ public function testPreventsLoginWithMultipleCase() for ($i = 0; $i < 3; ++$i) { $this->listener->checkPassport($this->createCheckPassportEvent($passports[$i % 3])); + $this->listener->onFailedLogin($this->createLoginFailedEvent($passports[$i % 3])); } $this->expectException(TooManyLoginAttemptsAuthenticationException::class); @@ -97,6 +93,7 @@ public function testPreventsLoginWhenOverGlobalThreshold() for ($i = 0; $i < 6; ++$i) { $this->listener->checkPassport($this->createCheckPassportEvent($passports[$i % 2])); + $this->listener->onFailedLogin($this->createLoginFailedEvent($passports[$i % 2])); } $this->expectException(TooManyLoginAttemptsAuthenticationException::class); @@ -108,9 +105,9 @@ private function createPassport($username) return new SelfValidatingPassport(new UserBadge($username)); } - private function createLoginSuccessfulEvent($passport) + private function createLoginFailedEvent($passport) { - return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->requestStack->getCurrentRequest(), null, 'main'); + return new LoginFailureEvent($this->createMock(AuthenticationException::class), $this->createMock(AuthenticatorInterface::class), $this->requestStack->getCurrentRequest(), null, 'main', $passport); } private function createCheckPassportEvent($passport) diff --git a/Tests/EventListener/PasswordMigratingListenerTest.php b/Tests/EventListener/PasswordMigratingListenerTest.php index 4e8ca62a..15671691 100644 --- a/Tests/EventListener/PasswordMigratingListenerTest.php +++ b/Tests/EventListener/PasswordMigratingListenerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; @@ -23,18 +24,17 @@ use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; -use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; use Symfony\Component\Security\Http\Tests\Fixtures\DummyAuthenticator; class PasswordMigratingListenerTest extends TestCase { - private $hasherFactory; - private $listener; - private $user; + private MockObject&PasswordHasherFactoryInterface $hasherFactory; + private PasswordMigratingListener $listener; + private UserInterface&PasswordAuthenticatedUserInterface $user; protected function setUp(): void { @@ -61,49 +61,10 @@ public function testUnsupportedEvents($event) public static function provideUnsupportedEvents() { // no password upgrade badge - yield [self::createEvent(new SelfValidatingPassport(new UserBadge('test', function () { return new DummyTestPasswordAuthenticatedUser(); })))]; + yield [self::createEvent(new SelfValidatingPassport(new UserBadge('test', fn () => new DummyTestPasswordAuthenticatedUser())))]; // blank password - yield [self::createEvent(new SelfValidatingPassport(new UserBadge('test', function () { return new DummyTestPasswordAuthenticatedUser(); }), [new PasswordUpgradeBadge('', self::createPasswordUpgrader())]))]; - } - - /** - * @group legacy - */ - public function testLegacyUnsupportedEvents() - { - $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); - - $this->listener->onLoginSuccess($this->createEvent($this->createMock(PassportInterface::class))); - } - - /** - * @group legacy - */ - public function testUnsupportedPassport() - { - // A custom Passport, without an UserBadge - $passport = $this->createMock(UserPassportInterface::class); - $passport->method('getUser')->willReturn($this->user); - $passport->method('hasBadge') - ->willReturnCallback(function (...$args) { - static $series = [ - [[PasswordUpgradeBadge::class], true], - [[UserBadge::class], false], - ]; - - [$expectedArgs, $return] = array_shift($series); - $this->assertSame($expectedArgs, $args); - - return $return; - }) - ; - $passport->expects($this->once())->method('getBadge')->with(PasswordUpgradeBadge::class)->willReturn(new PasswordUpgradeBadge('pa$$word')); - // We should never "getBadge" for "UserBadge::class" - - $event = $this->createEvent($passport); - - $this->listener->onLoginSuccess($event); + yield [self::createEvent(new SelfValidatingPassport(new UserBadge('test', fn () => new DummyTestPasswordAuthenticatedUser()), [new PasswordUpgradeBadge('', self::createPasswordUpgrader())]))]; } public function testUpgradeWithUpgrader() @@ -114,7 +75,7 @@ public function testUpgradeWithUpgrader() ->with($this->user, 'new-hash') ; - $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', function () { return $this->user; }), [new PasswordUpgradeBadge('pa$$word', $passwordUpgrader)])); + $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', fn () => $this->user), [new PasswordUpgradeBadge('pa$$word', $passwordUpgrader)])); $this->listener->onLoginSuccess($event); } @@ -131,7 +92,7 @@ public function testUpgradeWithoutUpgrader() $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', [$userLoader, 'loadUserByIdentifier']), [new PasswordUpgradeBadge('pa$$word')])); $this->listener->onLoginSuccess($event); - $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', \Closure::fromCallable([$userLoader, 'loadUserByIdentifier'])), [new PasswordUpgradeBadge('pa$$word')])); + $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', $userLoader->loadUserByIdentifier(...)), [new PasswordUpgradeBadge('pa$$word')])); $this->listener->onLoginSuccess($event); } @@ -141,7 +102,7 @@ public function testUserWithoutPassword() $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); - $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', function () { return $this->user; }), [new PasswordUpgradeBadge('pa$$word')])); + $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', fn () => $this->user), [new PasswordUpgradeBadge('pa$$word')])); $this->listener->onLoginSuccess($event); } @@ -150,7 +111,7 @@ private static function createPasswordUpgrader() return new DummyTestMigratingUserProvider(); } - private static function createEvent(PassportInterface $passport) + private static function createEvent(Passport $passport) { return new LoginSuccessEvent(new DummyAuthenticator(), $passport, new NullToken(), new Request(), null, 'main'); } @@ -189,8 +150,6 @@ public function loadUserByUsername(string $username): UserInterface abstract class TestPasswordAuthenticatedUser implements UserInterface, PasswordAuthenticatedUserInterface { abstract public function getPassword(): ?string; - - abstract public function getSalt(): ?string; } class DummyTestPasswordAuthenticatedUser extends TestPasswordAuthenticatedUser @@ -200,21 +159,13 @@ public function getPassword(): ?string return null; } - public function getSalt(): ?string - { - return null; - } - public function getRoles(): array { return []; } - public function eraseCredentials() - { - } - - public function getUsername(): string + #[\Deprecated] + public function eraseCredentials(): void { } diff --git a/Tests/EventListener/RememberMeListenerTest.php b/Tests/EventListener/RememberMeListenerTest.php index 0a923396..89a32c3a 100644 --- a/Tests/EventListener/RememberMeListenerTest.php +++ b/Tests/EventListener/RememberMeListenerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -19,7 +20,7 @@ use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\EventListener\RememberMeListener; @@ -27,10 +28,10 @@ class RememberMeListenerTest extends TestCase { - private $rememberMeHandler; - private $listener; - private $request; - private $response; + private MockObject&RememberMeHandlerInterface $rememberMeHandler; + private RememberMeListener $listener; + private Request $request; + private Response $response; protected function setUp(): void { @@ -64,11 +65,9 @@ public function testCredentialsInvalid() $this->listener->clearCookie(); } - private function createLoginSuccessfulEvent(?PassportInterface $passport = null) + private function createLoginSuccessfulEvent(?Passport $passport = null) { - if (null === $passport) { - $passport = $this->createPassport(); - } + $passport ??= $this->createPassport(); return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), $passport, $this->createMock(TokenInterface::class), $this->request, $this->response, 'main_firewall'); } @@ -81,6 +80,6 @@ private function createPassport(?array $badges = null) $badges = [$badge]; } - return new SelfValidatingPassport(new UserBadge('test', function ($username) { return new InMemoryUser($username, null); }), $badges); + return new SelfValidatingPassport(new UserBadge('test', fn ($username) => new InMemoryUser($username, null)), $badges); } } diff --git a/Tests/EventListener/RememberMeLogoutListenerTest.php b/Tests/EventListener/RememberMeLogoutListenerTest.php deleted file mode 100644 index 4e13262b..00000000 --- a/Tests/EventListener/RememberMeLogoutListenerTest.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\EventListener; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Http\Event\LogoutEvent; -use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener; -use Symfony\Component\Security\Http\RememberMe\AbstractRememberMeServices; - -/** - * @group legacy - */ -class RememberMeLogoutListenerTest extends TestCase -{ - public function testOnLogoutDoesNothingIfNoToken() - { - $rememberMeServices = $this->createMock(AbstractRememberMeServices::class); - $rememberMeServices->expects($this->never())->method('logout'); - - $rememberMeLogoutListener = new RememberMeLogoutListener($rememberMeServices); - $rememberMeLogoutListener->onLogout(new LogoutEvent(new Request(), null)); - } -} diff --git a/Tests/EventListener/SessionStrategyListenerTest.php b/Tests/EventListener/SessionStrategyListenerTest.php index 29ef9b68..e4a3c86e 100644 --- a/Tests/EventListener/SessionStrategyListenerTest.php +++ b/Tests/EventListener/SessionStrategyListenerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -26,10 +27,10 @@ class SessionStrategyListenerTest extends TestCase { - private $sessionAuthenticationStrategy; - private $listener; - private $request; - private $token; + private MockObject&SessionAuthenticationStrategyInterface $sessionAuthenticationStrategy; + private SessionStrategyListener $listener; + private Request $request; + private NullToken $token; protected function setUp(): void { @@ -104,7 +105,7 @@ public function testRequestWithSamePreviousUserButDifferentTokenType() private function createEvent($firewallName) { - return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), new SelfValidatingPassport(new UserBadge('test', function ($username) { return new InMemoryUser($username, null); })), $this->token, $this->request, null, $firewallName); + return new LoginSuccessEvent($this->createMock(AuthenticatorInterface::class), new SelfValidatingPassport(new UserBadge('test', fn ($username) => new InMemoryUser($username, null))), $this->token, $this->request, null, $firewallName); } private function configurePreviousSession() diff --git a/Tests/EventListener/UserCheckerListenerTest.php b/Tests/EventListener/UserCheckerListenerTest.php index a0077f75..eb4f764b 100644 --- a/Tests/EventListener/UserCheckerListenerTest.php +++ b/Tests/EventListener/UserCheckerListenerTest.php @@ -11,15 +11,14 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken; use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PreAuthenticatedUserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; use Symfony\Component\Security\Http\Event\CheckPassportEvent; @@ -27,9 +26,9 @@ class UserCheckerListenerTest extends TestCase { - private $userChecker; - private $listener; - private $user; + private MockObject&UserCheckerInterface $userChecker; + private UserCheckerListener $listener; + private InMemoryUser $user; protected function setUp(): void { @@ -45,21 +44,11 @@ public function testPreAuth() $this->listener->preCheckCredentials($this->createCheckPassportEvent()); } - /** - * @group legacy - */ - public function testPreAuthNoUser() - { - $this->userChecker->expects($this->never())->method('checkPreAuth'); - - $this->listener->preCheckCredentials($this->createCheckPassportEvent($this->createMock(PassportInterface::class))); - } - public function testPreAuthenticatedBadge() { $this->userChecker->expects($this->never())->method('checkPreAuth'); - $this->listener->preCheckCredentials($this->createCheckPassportEvent(new SelfValidatingPassport(new UserBadge('test', function () { return $this->user; }), [new PreAuthenticatedUserBadge()]))); + $this->listener->preCheckCredentials($this->createCheckPassportEvent(new SelfValidatingPassport(new UserBadge('test', fn () => $this->user), [new PreAuthenticatedUserBadge()]))); } public function testPostAuthValidCredentials() @@ -69,27 +58,18 @@ public function testPostAuthValidCredentials() $this->listener->postCheckCredentials(new AuthenticationSuccessEvent(new PostAuthenticationToken($this->user, 'main', []))); } - /** - * @group legacy - */ - public function testPostAuthNoUser() + public function testTokenIsPassedToPost() { - $this->userChecker->expects($this->never())->method('checkPostAuth'); + $token = new PostAuthenticationToken($this->user, 'main', []); + $this->userChecker->expects($this->once())->method('checkPostAuth')->with($this->user, $token); - $this->listener->postCheckCredentials(new AuthenticationSuccessEvent(new PreAuthenticatedToken('nobody', 'main'))); + $this->listener->postCheckCredentials(new AuthenticationSuccessEvent($token)); } private function createCheckPassportEvent($passport = null) { - if (null === $passport) { - $passport = new SelfValidatingPassport(new UserBadge('test', function () { return $this->user; })); - } + $passport ??= new SelfValidatingPassport(new UserBadge('test', fn () => $this->user)); return new CheckPassportEvent($this->createMock(AuthenticatorInterface::class), $passport); } - - private function createAuthenticationSuccessEvent() - { - return new AuthenticationSuccessEvent(new PostAuthenticationToken($this->user, 'main', [])); - } } diff --git a/Tests/EventListener/UserProviderListenerTest.php b/Tests/EventListener/UserProviderListenerTest.php index d81fc80c..63bf554c 100644 --- a/Tests/EventListener/UserProviderListenerTest.php +++ b/Tests/EventListener/UserProviderListenerTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\InMemoryUserProvider; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; @@ -23,10 +22,8 @@ class UserProviderListenerTest extends TestCase { - use ExpectDeprecationTrait; - - private $userProvider; - private $listener; + private InMemoryUserProvider $userProvider; + private UserProviderListener $listener; protected function setUp(): void { @@ -40,9 +37,6 @@ public function testSetUserProvider() $this->listener->checkPassport(new CheckPassportEvent($this->createMock(AuthenticatorInterface::class), $passport)); - $badge = $passport->getBadge(UserBadge::class); - $this->assertEquals([$this->userProvider, 'loadUserByIdentifier'], $badge->getUserLoader()); - $user = new InMemoryUser('wouter', null); $this->userProvider->createUser($user); $this->assertTrue($user->isEqualTo($passport->getUser())); diff --git a/Tests/Firewall/AbstractPreAuthenticatedListenerTest.php b/Tests/Firewall/AbstractPreAuthenticatedListenerTest.php deleted file mode 100644 index c32bd718..00000000 --- a/Tests/Firewall/AbstractPreAuthenticatedListenerTest.php +++ /dev/null @@ -1,249 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Firewall\AbstractPreAuthenticatedListener; - -/** - * @group legacy - */ -class AbstractPreAuthenticatedListenerTest extends TestCase -{ - public function testHandleWithValidValues() - { - $userCredentials = ['TheUser', 'TheCredentials']; - - $request = new Request([], [], [], [], [], []); - - $token = $this->createMock(TokenInterface::class); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($token)) - ; - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->isInstanceOf(PreAuthenticatedToken::class)) - ->willReturn($token) - ; - - $listener = $this->getMockBuilder(AbstractPreAuthenticatedListener::class) - ->setConstructorArgs([ - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - ]) - ->onlyMethods(['getPreAuthenticatedData']) - ->getMock(); - - $listener - ->expects($this->once()) - ->method('getPreAuthenticatedData') - ->willReturn($userCredentials); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandleWhenAuthenticationFails() - { - $userCredentials = ['TheUser', 'TheCredentials']; - - $request = new Request([], [], [], [], [], []); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - $tokenStorage - ->expects($this->never()) - ->method('setToken') - ; - - $exception = new AuthenticationException('Authentication failed.'); - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->isInstanceOf(PreAuthenticatedToken::class)) - ->willThrowException($exception) - ; - - $listener = $this->getMockBuilder(AbstractPreAuthenticatedListener::class) - ->setConstructorArgs([ - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - ]) - ->onlyMethods(['getPreAuthenticatedData']) - ->getMock(); - - $listener - ->expects($this->once()) - ->method('getPreAuthenticatedData') - ->willReturn($userCredentials); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandleWhenAuthenticationFailsWithDifferentToken() - { - $userCredentials = ['TheUser', 'TheCredentials']; - - $token = new UsernamePasswordToken('TheUsername', 'ThePassword', 'TheProviderKey', ['ROLE_FOO']); - - $request = new Request([], [], [], [], [], []); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($token) - ; - $tokenStorage - ->expects($this->never()) - ->method('setToken') - ; - - $exception = new AuthenticationException('Authentication failed.'); - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->isInstanceOf(PreAuthenticatedToken::class)) - ->willThrowException($exception) - ; - - $listener = $this->getMockBuilder(AbstractPreAuthenticatedListener::class) - ->setConstructorArgs([ - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - ]) - ->onlyMethods(['getPreAuthenticatedData']) - ->getMock(); - - $listener - ->expects($this->once()) - ->method('getPreAuthenticatedData') - ->willReturn($userCredentials); - - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $listener($event); - } - - public function testHandleWithASimilarAuthenticatedToken() - { - $userCredentials = ['TheUser', 'TheCredentials']; - - $request = new Request([], [], [], [], [], []); - - $token = new PreAuthenticatedToken('TheUser', 'TheCredentials', 'TheProviderKey', ['ROLE_FOO']); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($token) - ; - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->never()) - ->method('authenticate') - ; - - $listener = $this->getMockBuilder(AbstractPreAuthenticatedListener::class) - ->setConstructorArgs([ - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - ]) - ->onlyMethods(['getPreAuthenticatedData']) - ->getMock(); - - $listener - ->expects($this->once()) - ->method('getPreAuthenticatedData') - ->willReturn($userCredentials); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandleWithAnInvalidSimilarToken() - { - $userCredentials = ['TheUser', 'TheCredentials']; - - $request = new Request([], [], [], [], [], []); - - $token = new PreAuthenticatedToken('AnotherUser', 'TheCredentials', 'TheProviderKey', ['ROLE_FOO']); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($token) - ; - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo(null)) - ; - - $exception = new AuthenticationException('Authentication failed.'); - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->isInstanceOf(PreAuthenticatedToken::class)) - ->willThrowException($exception) - ; - - $listener = $this->getMockBuilder(AbstractPreAuthenticatedListener::class) - ->setConstructorArgs([ - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - ]) - ->onlyMethods(['getPreAuthenticatedData']) - ->getMock(); - - $listener - ->expects($this->once()) - ->method('getPreAuthenticatedData') - ->willReturn($userCredentials); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } -} diff --git a/Tests/Firewall/AccessListenerTest.php b/Tests/Firewall/AccessListenerTest.php index 4a13a4b7..83df93d3 100644 --- a/Tests/Firewall/AccessListenerTest.php +++ b/Tests/Firewall/AccessListenerTest.php @@ -15,17 +15,15 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\Exception\AccessDeniedException; -use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\AccessMapInterface; use Symfony\Component\Security\Http\Event\LazyResponseEvent; @@ -35,7 +33,6 @@ class AccessListenerTest extends TestCase { public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess() { - $this->expectException(AccessDeniedException::class); $request = new Request(); $accessMap = $this->createMock(AccessMapInterface::class); @@ -46,19 +43,7 @@ public function testHandleWhenTheAccessDecisionManagerDecidesToRefuseAccess() ->willReturn([['foo' => 'bar'], null]) ; - $token = new class() extends AbstractToken { - public function isAuthenticated(): bool - { - return true; - } - - /** - * @return mixed - */ - public function getCredentials() - { - } - }; + $token = new class extends AbstractToken {}; $tokenStorage = $this->createMock(TokenStorageInterface::class); $tokenStorage @@ -78,77 +63,10 @@ public function getCredentials() $listener = new AccessListener( $tokenStorage, $accessDecisionManager, - $accessMap, - false + $accessMap ); - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - /** - * @group legacy - */ - public function testHandleWhenTheTokenIsNotAuthenticated() - { - $request = new Request(); - - $accessMap = $this->createMock(AccessMapInterface::class); - $accessMap - ->expects($this->any()) - ->method('getPatterns') - ->with($this->equalTo($request)) - ->willReturn([['foo' => 'bar'], null]) - ; - - $notAuthenticatedToken = $this->createMock(TokenInterface::class); - $notAuthenticatedToken - ->expects($this->any()) - ->method('isAuthenticated') - ->willReturn(false) - ; - - $authenticatedToken = $this->createMock(TokenInterface::class); - $authenticatedToken - ->expects($this->any()) - ->method('isAuthenticated') - ->willReturn(true) - ; - - $authManager = $this->createMock(AuthenticationManagerInterface::class); - $authManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->equalTo($notAuthenticatedToken)) - ->willReturn($authenticatedToken) - ; - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($notAuthenticatedToken) - ; - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($authenticatedToken)) - ; - - $accessDecisionManager = $this->createMock(AccessDecisionManagerInterface::class); - $accessDecisionManager - ->expects($this->once()) - ->method('decide') - ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar']), $this->equalTo($request)) - ->willReturn(true) - ; - - $listener = new AccessListener( - $tokenStorage, - $accessDecisionManager, - $accessMap, - $authManager, - false - ); + $this->expectException(AccessDeniedException::class); $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } @@ -165,26 +83,16 @@ public function testHandleWhenThereIsNoAccessMapEntryMatchingTheRequest() ->willReturn([null, null]) ; - $token = $this->createMock(TokenInterface::class); - if (method_exists(TokenInterface::class, 'isAuthenticated')) { - $token - ->expects($this->never()) - ->method('isAuthenticated') - ; - } - $tokenStorage = $this->createMock(TokenStorageInterface::class); $tokenStorage - ->expects($this->any()) + ->expects($this->never()) ->method('getToken') - ->willReturn($token) ; $listener = new AccessListener( $tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), - $accessMap, - false + $accessMap ); $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); @@ -211,8 +119,7 @@ public function testHandleWhenAccessMapReturnsEmptyAttributes() $listener = new AccessListener( $tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), - $accessMap, - false + $accessMap ); $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); @@ -220,42 +127,8 @@ public function testHandleWhenAccessMapReturnsEmptyAttributes() $listener(new LazyResponseEvent($event)); } - /** - * @group legacy - */ - public function testLegacyHandleWhenTheSecurityTokenStorageHasNoToken() - { - $this->expectException(AuthenticationCredentialsNotFoundException::class); - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $request = new Request(); - - $accessMap = $this->createMock(AccessMapInterface::class); - $accessMap - ->expects($this->any()) - ->method('getPatterns') - ->with($this->equalTo($request)) - ->willReturn([['foo' => 'bar'], null]) - ; - - $listener = new AccessListener( - $tokenStorage, - $this->createMock(AccessDecisionManagerInterface::class), - $accessMap, - $this->createMock(AuthenticationManagerInterface::class) - ); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - public function testHandleWhenTheSecurityTokenStorageHasNoToken() { - $this->expectException(AccessDeniedException::class); $tokenStorage = new TokenStorage(); $request = new Request(); @@ -279,6 +152,8 @@ public function testHandleWhenTheSecurityTokenStorageHasNoToken() false ); + $this->expectException(AccessDeniedException::class); + $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } @@ -361,15 +236,14 @@ public function testHandleMWithultipleAttributesShouldBeHandledAsAnd() $accessDecisionManager ->expects($this->once()) ->method('decide') - ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), true) + ->with($this->equalTo($authenticatedToken), $this->equalTo(['foo' => 'bar', 'bar' => 'baz']), $this->equalTo($request), new AccessDecision(), true) ->willReturn(true) ; $listener = new AccessListener( $tokenStorage, $accessDecisionManager, - $accessMap, - false + $accessMap ); $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); @@ -392,23 +266,17 @@ public function testLazyPublicPagesShouldNotAccessTokenStorage() $listener(new LazyResponseEvent(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST))); } - /** - * @group legacy - */ - public function testLegacyLazyPublicPagesShouldNotAccessTokenStorage() + public function testConstructWithTrueExceptionOnNoToken() { $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage->expects($this->never())->method('getToken'); + $tokenStorage->expects($this->never())->method(self::anything()); - $request = new Request(); $accessMap = $this->createMock(AccessMapInterface::class); - $accessMap->expects($this->any()) - ->method('getPatterns') - ->with($this->equalTo($request)) - ->willReturn([[AuthenticatedVoter::IS_AUTHENTICATED_ANONYMOUSLY], null]) - ; - $listener = new AccessListener($tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), $accessMap, false); - $listener(new LazyResponseEvent(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST))); + $this->expectExceptionObject( + new \LogicException('Argument $exceptionOnNoToken of "Symfony\Component\Security\Http\Firewall\AccessListener::__construct()" must be set to "false".') + ); + + new AccessListener($tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), $accessMap, true); } } diff --git a/Tests/Firewall/AnonymousAuthenticationListenerTest.php b/Tests/Firewall/AnonymousAuthenticationListenerTest.php deleted file mode 100644 index 235f6670..00000000 --- a/Tests/Firewall/AnonymousAuthenticationListenerTest.php +++ /dev/null @@ -1,98 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Http\Firewall\AnonymousAuthenticationListener; - -/** - * @group legacy - */ -class AnonymousAuthenticationListenerTest extends TestCase -{ - public function testHandleWithTokenStorageHavingAToken() - { - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($this->createMock(TokenInterface::class)) - ; - $tokenStorage - ->expects($this->never()) - ->method('setToken') - ; - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->never()) - ->method('authenticate') - ; - - $listener = new AnonymousAuthenticationListener($tokenStorage, 'TheSecret', null, $authenticationManager); - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandleWithTokenStorageHavingNoToken() - { - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $anonymousToken = new AnonymousToken('TheSecret', 'anon.', []); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->callback(function ($token) { - return 'TheSecret' === $token->getSecret(); - })) - ->willReturn($anonymousToken) - ; - - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($anonymousToken) - ; - - $listener = new AnonymousAuthenticationListener($tokenStorage, 'TheSecret', null, $authenticationManager); - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandledEventIsLogged() - { - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $logger = $this->createMock(LoggerInterface::class); - $logger->expects($this->once()) - ->method('info') - ->with('Populated the TokenStorage with an anonymous Token.') - ; - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new AnonymousAuthenticationListener($tokenStorage, 'TheSecret', $logger, $authenticationManager); - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } -} diff --git a/Tests/Firewall/BasicAuthenticationListenerTest.php b/Tests/Firewall/BasicAuthenticationListenerTest.php deleted file mode 100644 index 36db0b5a..00000000 --- a/Tests/Firewall/BasicAuthenticationListenerTest.php +++ /dev/null @@ -1,220 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager; -use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; -use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; -use Symfony\Component\Security\Http\Firewall\BasicAuthenticationListener; - -/** - * @group legacy - */ -class BasicAuthenticationListenerTest extends TestCase -{ - public function testHandleWithValidUsernameAndPasswordServerParameters() - { - $request = new Request([], [], [], [], [], [ - 'PHP_AUTH_USER' => 'TheUsername', - 'PHP_AUTH_PW' => 'ThePassword', - ]); - - $token = $this->createMock(TokenInterface::class); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($token)) - ; - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->once()) - ->method('authenticate') - ->with($this->isInstanceOf(UsernamePasswordToken::class)) - ->willReturn($token) - ; - - $listener = new BasicAuthenticationListener( - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - $this->createMock(AuthenticationEntryPointInterface::class) - ); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandleWhenAuthenticationFails() - { - $request = new Request([], [], [], [], [], [ - 'PHP_AUTH_USER' => 'TheUsername', - 'PHP_AUTH_PW' => 'ThePassword', - ]); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - $tokenStorage - ->expects($this->never()) - ->method('setToken') - ; - - $response = new Response(); - - $authenticationEntryPoint = $this->createMock(AuthenticationEntryPointInterface::class); - $authenticationEntryPoint - ->expects($this->any()) - ->method('start') - ->with($this->equalTo($request), $this->isInstanceOf(AuthenticationException::class)) - ->willReturn($response) - ; - - $listener = new BasicAuthenticationListener( - $tokenStorage, - new AuthenticationProviderManager([$this->createMock(AuthenticationProviderInterface::class)]), - 'TheProviderKey', - $authenticationEntryPoint - ); - - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $listener($event); - - $this->assertSame($response, $event->getResponse()); - } - - public function testHandleWithNoUsernameServerParameter() - { - $request = new Request(); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->never()) - ->method('getToken') - ; - - $listener = new BasicAuthenticationListener( - $tokenStorage, - $this->createMock(AuthenticationManagerInterface::class), - 'TheProviderKey', - $this->createMock(AuthenticationEntryPointInterface::class) - ); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testHandleWithASimilarAuthenticatedToken() - { - $request = new Request([], [], [], [], [], ['PHP_AUTH_USER' => 'TheUsername']); - - $token = new UsernamePasswordToken('TheUsername', 'ThePassword', 'TheProviderKey', ['ROLE_FOO']); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($token) - ; - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - $authenticationManager - ->expects($this->never()) - ->method('authenticate') - ; - - $listener = new BasicAuthenticationListener( - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - $this->createMock(AuthenticationEntryPointInterface::class) - ); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testItRequiresProviderKey() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('$providerKey must not be empty'); - new BasicAuthenticationListener( - $this->createMock(TokenStorageInterface::class), - $this->createMock(AuthenticationManagerInterface::class), - '', - $this->createMock(AuthenticationEntryPointInterface::class) - ); - } - - public function testHandleWithADifferentAuthenticatedToken() - { - $request = new Request([], [], [], [], [], [ - 'PHP_AUTH_USER' => 'TheUsername', - 'PHP_AUTH_PW' => 'ThePassword', - ]); - - $token = new PreAuthenticatedToken('TheUser', 'TheCredentials', 'TheProviderKey', ['ROLE_FOO']); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($token) - ; - $tokenStorage - ->expects($this->never()) - ->method('setToken') - ; - - $response = new Response(); - - $authenticationEntryPoint = $this->createMock(AuthenticationEntryPointInterface::class); - $authenticationEntryPoint - ->expects($this->any()) - ->method('start') - ->with($this->equalTo($request), $this->isInstanceOf(AuthenticationException::class)) - ->willReturn($response) - ; - - $listener = new BasicAuthenticationListener( - $tokenStorage, - new AuthenticationProviderManager([$this->createMock(AuthenticationProviderInterface::class)]), - 'TheProviderKey', - $authenticationEntryPoint - ); - - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $listener($event); - - $this->assertSame($response, $event->getResponse()); - } -} diff --git a/Tests/Firewall/ChannelListenerTest.php b/Tests/Firewall/ChannelListenerTest.php index ee025f55..06c4c6d0 100644 --- a/Tests/Firewall/ChannelListenerTest.php +++ b/Tests/Firewall/ChannelListenerTest.php @@ -18,7 +18,6 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Security\Http\AccessMapInterface; -use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\Firewall\ChannelListener; class ChannelListenerTest extends TestCase @@ -149,42 +148,4 @@ public function testSupportsWithoutHeaders() $this->assertTrue($listener->supports($request)); } - - /** - * @group legacy - */ - public function testLegacyHandleWithEntryPoint() - { - $request = $this->createMock(Request::class); - $request - ->expects($this->any()) - ->method('isSecure') - ->willReturn(false) - ; - - $accessMap = $this->createMock(AccessMapInterface::class); - $accessMap - ->expects($this->any()) - ->method('getPatterns') - ->with($this->equalTo($request)) - ->willReturn([[], 'https']) - ; - - $response = new RedirectResponse('/redirected'); - - $entryPoint = $this->createMock(AuthenticationEntryPointInterface::class); - $entryPoint - ->expects($this->once()) - ->method('start') - ->with($this->equalTo($request)) - ->willReturn($response) - ; - - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $listener = new ChannelListener($accessMap, $entryPoint); - $listener($event); - - $this->assertSame($response, $event->getResponse()); - } } diff --git a/Tests/Firewall/ContextListenerTest.php b/Tests/Firewall/ContextListenerTest.php index 5389e54a..585fca8a 100644 --- a/Tests/Firewall/ContextListenerTest.php +++ b/Tests/Firewall/ContextListenerTest.php @@ -25,7 +25,6 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\UsageTrackingTokenStorage; @@ -36,9 +35,9 @@ use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; -use Symfony\Component\Security\Http\Event\DeauthenticatedEvent; use Symfony\Component\Security\Http\Firewall\ContextListener; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; +use Symfony\Component\Security\Http\Tests\Fixtures\CustomUser; +use Symfony\Component\Security\Http\Tests\Fixtures\NullUserToken; use Symfony\Contracts\Service\ServiceLocatorTrait; class ContextListenerTest extends TestCase @@ -61,6 +60,30 @@ public function testUserProvidersNeedToImplementAnInterface() $this->handleEventWithPreviousSession([new \stdClass()]); } + public function testTokenReturnsNullUser() + { + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken(new NullUserToken()); + + $session = new Session(new MockArraySessionStorage()); + $session->set('_security_context_key', serialize($tokenStorage->getToken())); + + $request = new Request(); + $request->setSession($session); + $request->cookies->set('MOCKSESSID', true); + + $listener = new ContextListener($tokenStorage, [], 'context_key'); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot authenticate a "Symfony\Component\Security\Http\Tests\Fixtures\NullUserToken" token because it doesn\'t store a user.'); + + $listener->authenticate(new RequestEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + } + public function testOnKernelResponseWillAddSession() { $session = $this->runSessionOnKernelResponse( @@ -95,16 +118,6 @@ public function testOnKernelResponseWillRemoveSession() $this->assertFalse($session->has('_security_session')); } - /** - * @group legacy - */ - public function testOnKernelResponseWillRemoveSessionOnAnonymousToken() - { - $session = $this->runSessionOnKernelResponse(new AnonymousToken('secret', 'anon.'), 'C:10:"serialized"'); - - $this->assertFalse($session->has('_security_session')); - } - public function testOnKernelResponseWithoutSession() { $tokenStorage = new TokenStorage(); @@ -188,7 +201,7 @@ public function testHandleAddsKernelResponseListener() $dispatcher->expects($this->once()) ->method('addListener') - ->with(KernelEvents::RESPONSE, [$listener, 'onKernelResponse']); + ->with(KernelEvents::RESPONSE, $listener->onKernelResponse(...)); $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); } @@ -210,7 +223,7 @@ public function testOnKernelResponseListenerRemovesItself() $dispatcher->expects($this->once()) ->method('removeListener') - ->with(KernelEvents::RESPONSE, [$listener, 'onKernelResponse']); + ->with(KernelEvents::RESPONSE, $listener->onKernelResponse(...)); $listener->onKernelResponse($event); } @@ -264,22 +277,6 @@ public function testIfTokenIsNotDeauthenticated() $this->assertSame($goodRefreshedUser, $tokenStorage->getToken()->getUser()); } - /** - * @group legacy - */ - public function testRememberMeGetsCanceledIfTokenIsDeauthenticated() - { - $tokenStorage = new TokenStorage(); - $refreshedUser = new InMemoryUser('foobar', 'baz'); - - $rememberMeServices = $this->createMock(RememberMeServicesInterface::class); - $rememberMeServices->expects($this->once())->method('loginFail'); - - $tokenStorage = $this->handleEventWithPreviousSession([new NotSupportingUserProvider(true), new NotSupportingUserProvider(false), new SupportingUserProvider($refreshedUser)], null, $rememberMeServices); - - $this->assertNull($tokenStorage->getToken()); - } - public function testTryAllUserProvidersUntilASupportingUserProviderIsFound() { $refreshedUser = new InMemoryUser('foobar', 'baz'); @@ -317,36 +314,6 @@ public function testAcceptsProvidersAsTraversable() $this->assertSame($refreshedUser, $tokenStorage->getToken()->getUser()); } - /** - * @group legacy - */ - public function testDeauthenticatedEvent() - { - $tokenStorage = new TokenStorage(); - $refreshedUser = new InMemoryUser('foobar', 'baz'); - - $user = new InMemoryUser('foo', 'bar'); - $session = new Session(new MockArraySessionStorage()); - $session->set('_security_context_key', serialize(new UsernamePasswordToken($user, 'context_key', ['ROLE_USER']))); - - $request = new Request(); - $request->setSession($session); - $request->cookies->set('MOCKSESSID', true); - - $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(DeauthenticatedEvent::class, function (DeauthenticatedEvent $event) use ($user) { - $this->assertTrue($event->getOriginalToken()->isAuthenticated()); - $this->assertEquals($event->getOriginalToken()->getUser(), $user); - $this->assertFalse($event->getRefreshedToken()->isAuthenticated()); - $this->assertNotEquals($event->getRefreshedToken()->getUser(), $user); - }); - - $listener = new ContextListener($tokenStorage, [new NotSupportingUserProvider(true), new NotSupportingUserProvider(false), new SupportingUserProvider($refreshedUser)], 'context_key', null, $eventDispatcher); - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - - $this->assertNull($tokenStorage->getToken()); - } - public function testWithPreviousNotStartedSession() { $session = new Session(new MockArraySessionStorage()); @@ -358,7 +325,7 @@ public function testWithPreviousNotStartedSession() $usageIndex = $session->getUsageIndex(); $tokenStorage = new TokenStorage(); - $listener = new ContextListener($tokenStorage, [], 'context_key', null, null, null, [$tokenStorage, 'getToken']); + $listener = new ContextListener($tokenStorage, [], 'context_key', null, null, null, $tokenStorage->getToken(...)); $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); $this->assertSame($usageIndex, $session->getUsageIndex()); @@ -380,8 +347,10 @@ public function testSessionIsNotReported() $tokenStorage = new TokenStorage(); - $listener = new ContextListener($tokenStorage, [], 'context_key', null, null, null, [$tokenStorage, 'getToken']); + $listener = new ContextListener($tokenStorage, [], 'context_key', null, null, null, $tokenStorage->getToken(...)); $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); + + $listener->onKernelResponse(new ResponseEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new Response())); } public function testOnKernelResponseRemoveListener() @@ -398,14 +367,38 @@ public function testOnKernelResponseRemoveListener() $dispatcher = new EventDispatcher(); $httpKernel = $this->createMock(HttpKernelInterface::class); - $listener = new ContextListener($tokenStorage, [], 'session', null, $dispatcher, null, \Closure::fromCallable([$tokenStorage, 'getToken'])); - $this->assertEmpty($dispatcher->getListeners()); + $listener = new ContextListener($tokenStorage, [], 'session', null, $dispatcher, null, $tokenStorage->getToken(...)); + $this->assertSame([], $dispatcher->getListeners()); $listener(new RequestEvent($httpKernel, $request, HttpKernelInterface::MAIN_REQUEST)); $this->assertNotEmpty($dispatcher->getListeners()); $listener->onKernelResponse(new ResponseEvent($httpKernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response())); - $this->assertEmpty($dispatcher->getListeners()); + $this->assertSame([], $dispatcher->getListeners()); + } + + /** + * @testWith [true] + * [false] + * [null] + */ + public function testNullOrHashedPasswordInSessionDoesntInvalidateTheToken(?bool $hashPassword) + { + $user = new CustomUser('user', ['ROLE_USER'], 'pass', $hashPassword); + + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('supportsClass') + ->with(CustomUser::class) + ->willReturn(true); + $userProvider->expects($this->once()) + ->method('refreshUser') + ->willReturn($user); + + $tokenStorage = $this->handleEventWithPreviousSession([$userProvider], $user); + + $this->assertInstanceOf(UsernamePasswordToken::class, $tokenStorage->getToken()); + $this->assertSame($user, $tokenStorage->getToken()->getUser()); } protected function runSessionOnKernelResponse($newToken, $original = null) @@ -421,7 +414,7 @@ protected function runSessionOnKernelResponse($newToken, $original = null) $session->set('_security_session', $original); } - $factories = ['request_stack' => function () use ($requestStack) { return $requestStack; }]; + $factories = ['request_stack' => fn () => $requestStack]; $tokenStorage = new UsageTrackingTokenStorage(new TokenStorage(), new class($factories) implements ContainerInterface { use ServiceLocatorTrait; }); @@ -440,7 +433,7 @@ protected function runSessionOnKernelResponse($newToken, $original = null) new Response() ); - $listener = new ContextListener($tokenStorage, [], 'session', null, new EventDispatcher(), null, [$tokenStorage, 'enableUsageTracking']); + $listener = new ContextListener($tokenStorage, [], 'session', null, new EventDispatcher(), null, $tokenStorage->enableUsageTracking(...)); $listener->onKernelResponse($event); if ($session->getId() === $sessionId) { @@ -452,7 +445,7 @@ protected function runSessionOnKernelResponse($newToken, $original = null) return $session; } - private function handleEventWithPreviousSession($userProviders, ?UserInterface $user = null, ?RememberMeServicesInterface $rememberMeServices = null) + private function handleEventWithPreviousSession($userProviders, ?UserInterface $user = null) { $tokenUser = $user ?? new InMemoryUser('foo', 'bar'); $session = new Session(new MockArraySessionStorage()); @@ -467,22 +460,14 @@ private function handleEventWithPreviousSession($userProviders, ?UserInterface $ $tokenStorage = new TokenStorage(); $usageIndex = $session->getUsageIndex(); - if ((new \ReflectionClass(UsageTrackingTokenStorage::class))->hasMethod('getSession')) { - $factories = ['request_stack' => function () use ($requestStack) { return $requestStack; }]; - } else { - // BC for symfony/framework-bundle < 5.3 - $factories = ['session' => function () use ($session) { return $session; }]; - } + $factories = ['request_stack' => fn () => $requestStack]; $tokenStorage = new UsageTrackingTokenStorage($tokenStorage, new class($factories) implements ContainerInterface { use ServiceLocatorTrait; }); - $sessionTrackerEnabler = [$tokenStorage, 'enableUsageTracking']; + $sessionTrackerEnabler = $tokenStorage->enableUsageTracking(...); $listener = new ContextListener($tokenStorage, $userProviders, 'context_key', null, null, null, $sessionTrackerEnabler); - if ($rememberMeServices) { - $listener->setRememberMeServices($rememberMeServices); - } $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); if (null !== $user) { @@ -499,8 +484,7 @@ private function handleEventWithPreviousSession($userProviders, ?UserInterface $ class NotSupportingUserProvider implements UserProviderInterface { - /** @var bool */ - private $throwsUnsupportedException; + private bool $throwsUnsupportedException; public function __construct($throwsUnsupportedException) { @@ -534,7 +518,7 @@ public function supportsClass($class): bool class SupportingUserProvider implements UserProviderInterface { - private $refreshedUser; + private ?InMemoryUser $refreshedUser; public function __construct(?InMemoryUser $refreshedUser = null) { @@ -568,10 +552,10 @@ public function supportsClass($class): bool } } -abstract class BaseCustomToken implements TokenInterface +class CustomToken implements TokenInterface { - private $user; - private $roles; + private UserInterface $user; + private array $roles; public function __construct(UserInterface $user, array $roles) { @@ -584,21 +568,11 @@ public function __serialize(): array return [$this->user, $this->roles]; } - public function serialize(): string - { - return serialize($this->__serialize()); - } - public function __unserialize(array $data): void { [$this->user, $this->roles] = $data; } - public function unserialize($serialized) - { - $this->__unserialize(\is_array($serialized) ? $serialized : unserialize($serialized)); - } - public function __toString(): string { return $this->user->getUserIdentifier(); @@ -609,40 +583,23 @@ public function getRoleNames(): array return $this->roles; } - public function getCredentials() - { - } - public function getUser(): UserInterface { return $this->user; } - public function setUser($user) + public function setUser($user): void { $this->user = $user; } - public function getUsername(): string - { - return $this->user->getUserIdentifier(); - } - public function getUserIdentifier(): string { return $this->getUserIdentifier(); } - public function isAuthenticated(): bool - { - return true; - } - - public function setAuthenticated(bool $isAuthenticated) - { - } - - public function eraseCredentials() + #[\Deprecated] + public function eraseCredentials(): void { } @@ -651,7 +608,7 @@ public function getAttributes(): array return []; } - public function setAttributes(array $attributes) + public function setAttributes(array $attributes): void { } @@ -660,24 +617,12 @@ public function hasAttribute(string $name): bool return false; } - public function setAttribute(string $name, $value) + public function getAttribute(string $name): mixed { + return null; } -} -if (\PHP_VERSION_ID >= 80000) { - class CustomToken extends BaseCustomToken + public function setAttribute(string $name, $value): void { - public function getAttribute(string $name): mixed - { - return null; - } - } -} else { - class CustomToken extends BaseCustomToken - { - public function getAttribute(string $name) - { - } } } diff --git a/Tests/Firewall/ExceptionListenerTest.php b/Tests/Firewall/ExceptionListenerTest.php index 4e245b8a..07978379 100644 --- a/Tests/Firewall/ExceptionListenerTest.php +++ b/Tests/Firewall/ExceptionListenerTest.php @@ -75,20 +75,6 @@ public static function getAuthenticationExceptionProvider() ]; } - public function testExceptionWhenEntryPointReturnsBadValue() - { - $event = $this->createEvent(new AuthenticationException()); - - $entryPoint = $this->createMock(AuthenticationEntryPointInterface::class); - $entryPoint->expects($this->once())->method('start')->willReturn('NOT A RESPONSE'); - - $listener = $this->createExceptionListener(null, null, null, $entryPoint); - $listener->onKernelException($event); - // the exception has been replaced by our LogicException - $this->assertInstanceOf(\LogicException::class, $event->getThrowable()); - $this->assertStringEndsWith('start()" method must return a Response object ("string" returned).', $event->getThrowable()->getMessage()); - } - /** * @dataProvider getAccessDeniedExceptionProvider */ @@ -180,7 +166,7 @@ public function testUnregister() $this->assertNotEmpty($dispatcher->getListeners()); $listener->unregister($dispatcher); - $this->assertEmpty($dispatcher->getListeners()); + $this->assertSame([], $dispatcher->getListeners()); } public static function getAccessDeniedExceptionProvider() @@ -212,9 +198,7 @@ private function createTrustResolver($fullFledged) private function createEvent(\Exception $exception, $kernel = null) { - if (null === $kernel) { - $kernel = $this->createMock(HttpKernelInterface::class); - } + $kernel ??= $this->createMock(HttpKernelInterface::class); return new ExceptionEvent($kernel, Request::create('/'), HttpKernelInterface::MAIN_REQUEST, $exception); } diff --git a/Tests/Firewall/LogoutListenerTest.php b/Tests/Firewall/LogoutListenerTest.php index 57c23b5c..c7cdc7ab 100644 --- a/Tests/Firewall/LogoutListenerTest.php +++ b/Tests/Firewall/LogoutListenerTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Security\Http\Tests\Firewall; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,13 +24,9 @@ use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\Firewall\LogoutListener; use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface; -use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; class LogoutListenerTest extends TestCase { - use ExpectDeprecationTrait; - public function testHandleUnmatchedPath() { $dispatcher = $this->getEventDispatcher(); @@ -127,8 +122,6 @@ public function testHandleMatchedPathWithoutCsrfValidation() public function testNoResponseSet() { - $this->expectException(\RuntimeException::class); - [$listener, , $httpUtils, $options] = $this->getListener(); $request = new Request(); @@ -138,6 +131,8 @@ public function testNoResponseSet() ->with($request, $options['logout_path']) ->willReturn(true); + $this->expectException(\RuntimeException::class); + $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } @@ -146,7 +141,6 @@ public function testNoResponseSet() */ public function testCsrfValidationFails($invalidToken) { - $this->expectException(LogoutException::class); $tokenManager = $this->getTokenManager(); [$listener, , $httpUtils, $options] = $this->getListener(null, $tokenManager); @@ -165,43 +159,9 @@ public function testCsrfValidationFails($invalidToken) ->method('isTokenValid') ->willReturn(false); - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - /** - * @group legacy - */ - public function testLegacyLogoutHandlers() - { - $this->expectDeprecation('Since symfony/security-http 5.1: The "%s\LogoutSuccessHandlerInterface" interface is deprecated, create a listener for the "%s" event instead.'); - $this->expectDeprecation('Since symfony/security-http 5.1: Passing a logout success handler to "%s\LogoutListener::__construct" is deprecated, pass an instance of "%s" instead.'); - $this->expectDeprecation('Since symfony/security-http 5.1: Calling "%s::addHandler" is deprecated, register a listener on the "%s" event instead.'); - - $logoutSuccessHandler = $this->createMock(LogoutSuccessHandlerInterface::class); - [$listener, $tokenStorage, $httpUtils, $options] = $this->getListener($logoutSuccessHandler); - - $token = $this->getToken(); - $tokenStorage->expects($this->any())->method('getToken')->willReturn($token); - - $request = new Request(); - - $httpUtils->expects($this->once()) - ->method('checkRequestPath') - ->with($request, $options['logout_path']) - ->willReturn(true); - - $response = new Response(); - $logoutSuccessHandler->expects($this->any())->method('onLogoutSuccess')->willReturn($response); - - $handler = $this->createMock(LogoutHandlerInterface::class); - $handler->expects($this->once())->method('logout')->with($request, $response, $token); - $listener->addHandler($handler); - - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $listener($event); + $this->expectException(LogoutException::class); - $this->assertSame($response, $event->getResponse()); + $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); } public static function provideInvalidCsrfTokens(): array diff --git a/Tests/Firewall/RememberMeListenerTest.php b/Tests/Firewall/RememberMeListenerTest.php deleted file mode 100644 index d2e1ceec..00000000 --- a/Tests/Firewall/RememberMeListenerTest.php +++ /dev/null @@ -1,380 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Session\SessionInterface; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; -use Symfony\Component\Security\Http\Firewall\RememberMeListener; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; -use Symfony\Component\Security\Http\SecurityEvents; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -/** - * @group legacy - */ -class RememberMeListenerTest extends TestCase -{ - public function testOnCoreSecurityDoesNotTryToPopulateNonEmptyTokenStorage() - { - [$listener, $tokenStorage] = $this->getListener(); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn($this->createMock(TokenInterface::class)) - ; - - $tokenStorage - ->expects($this->never()) - ->method('setToken') - ; - - $this->assertNull($listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST))); - } - - public function testOnCoreSecurityDoesNothingWhenNoCookieIsSet() - { - [$listener, $tokenStorage, $service] = $this->getListener(); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn(null) - ; - - $this->assertNull($listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST))); - } - - public function testOnCoreSecurityIgnoresAuthenticationExceptionThrownByAuthenticationManagerImplementation() - { - [$listener, $tokenStorage, $service, $manager] = $this->getListener(); - $request = new Request(); - $exception = new AuthenticationException('Authentication failed.'); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn($this->createMock(TokenInterface::class)) - ; - - $service - ->expects($this->once()) - ->method('loginFail') - ->with($request, $exception) - ; - - $manager - ->expects($this->once()) - ->method('authenticate') - ->willThrowException($exception) - ; - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testOnCoreSecurityIgnoresAuthenticationOptionallyRethrowsExceptionThrownAuthenticationManagerImplementation() - { - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage('Authentication failed.'); - [$listener, $tokenStorage, $service, $manager] = $this->getListener(false, false); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn($this->createMock(TokenInterface::class)) - ; - - $service - ->expects($this->once()) - ->method('loginFail') - ; - - $exception = new AuthenticationException('Authentication failed.'); - $manager - ->expects($this->once()) - ->method('authenticate') - ->willThrowException($exception) - ; - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } - - public function testOnCoreSecurityAuthenticationExceptionDuringAutoLoginTriggersLoginFail() - { - [$listener, $tokenStorage, $service, $manager] = $this->getListener(); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $exception = new AuthenticationException('Authentication failed.'); - $service - ->expects($this->once()) - ->method('autoLogin') - ->willThrowException($exception) - ; - - $service - ->expects($this->once()) - ->method('loginFail') - ; - - $manager - ->expects($this->never()) - ->method('authenticate') - ; - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } - - public function testOnCoreSecurity() - { - [$listener, $tokenStorage, $service, $manager] = $this->getListener(); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $token = $this->createMock(TokenInterface::class); - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn($token) - ; - - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($token)) - ; - - $manager - ->expects($this->once()) - ->method('authenticate') - ->willReturn($token) - ; - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } - - public function testSessionStrategy() - { - [$listener, $tokenStorage, $service, $manager, , , $sessionStrategy] = $this->getListener(false, true, true); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $token = $this->createMock(TokenInterface::class); - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn($token) - ; - - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($token)) - ; - - $manager - ->expects($this->once()) - ->method('authenticate') - ->willReturn($token) - ; - - $session = $this->createMock(SessionInterface::class); - $session - ->expects($this->once()) - ->method('isStarted') - ->willReturn(true) - ; - - $request = new Request(); - $request->setSession($session); - - $sessionStrategy - ->expects($this->once()) - ->method('onAuthentication') - ->willReturn(null) - ; - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testSessionIsMigratedByDefault() - { - [$listener, $tokenStorage, $service, $manager] = $this->getListener(false, true, false); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $token = $this->createMock(TokenInterface::class); - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn($token) - ; - - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($token)) - ; - - $manager - ->expects($this->once()) - ->method('authenticate') - ->willReturn($token) - ; - - $session = $this->createMock(SessionInterface::class); - $session - ->expects($this->once()) - ->method('isStarted') - ->willReturn(true) - ; - $session - ->expects($this->once()) - ->method('migrate') - ; - - $request = new Request(); - $request->setSession($session); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public function testOnCoreSecurityInteractiveLoginEventIsDispatchedIfDispatcherIsPresent() - { - [$listener, $tokenStorage, $service, $manager, , $dispatcher] = $this->getListener(true); - - $tokenStorage - ->expects($this->any()) - ->method('getToken') - ->willReturn(null) - ; - - $token = $this->createMock(TokenInterface::class); - $service - ->expects($this->once()) - ->method('autoLogin') - ->willReturn($token) - ; - - $tokenStorage - ->expects($this->once()) - ->method('setToken') - ->with($this->equalTo($token)) - ; - - $manager - ->expects($this->once()) - ->method('authenticate') - ->willReturn($token) - ; - - $dispatcher - ->expects($this->once()) - ->method('dispatch') - ->with( - $this->isInstanceOf(InteractiveLoginEvent::class), - SecurityEvents::INTERACTIVE_LOGIN - ) - ; - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST)); - } - - protected function getListener($withDispatcher = false, $catchExceptions = true, $withSessionStrategy = false) - { - $listener = new RememberMeListener( - $tokenStorage = $this->getTokenStorage(), - $service = $this->getService(), - $manager = $this->getManager(), - $logger = $this->getLogger(), - $dispatcher = ($withDispatcher ? $this->getDispatcher() : null), - $catchExceptions, - $sessionStrategy = ($withSessionStrategy ? $this->getSessionStrategy() : null) - ); - - return [$listener, $tokenStorage, $service, $manager, $logger, $dispatcher, $sessionStrategy]; - } - - protected function getLogger() - { - return $this->createMock(LoggerInterface::class); - } - - protected function getManager() - { - return $this->createMock(AuthenticationManagerInterface::class); - } - - protected function getService() - { - return $this->createMock(RememberMeServicesInterface::class); - } - - protected function getTokenStorage() - { - return $this->createMock(TokenStorageInterface::class); - } - - protected function getDispatcher() - { - return $this->createMock(EventDispatcherInterface::class); - } - - private function getSessionStrategy() - { - return $this->createMock(SessionAuthenticationStrategyInterface::class); - } -} diff --git a/Tests/Firewall/RemoteUserAuthenticationListenerTest.php b/Tests/Firewall/RemoteUserAuthenticationListenerTest.php deleted file mode 100644 index 2dac33fc..00000000 --- a/Tests/Firewall/RemoteUserAuthenticationListenerTest.php +++ /dev/null @@ -1,96 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Http\Firewall\RemoteUserAuthenticationListener; - -/** - * @group legacy - */ -class RemoteUserAuthenticationListenerTest extends TestCase -{ - public function testGetPreAuthenticatedData() - { - $serverVars = [ - 'REMOTE_USER' => 'TheUser', - ]; - - $request = new Request([], [], [], [], [], $serverVars); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new RemoteUserAuthenticationListener( - $tokenStorage, - $authenticationManager, - 'TheProviderKey' - ); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $result = $method->invokeArgs($listener, [$request]); - $this->assertSame($result, ['TheUser', null]); - } - - public function testGetPreAuthenticatedDataNoUser() - { - $this->expectException(BadCredentialsException::class); - $request = new Request([], [], [], [], [], []); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new RemoteUserAuthenticationListener( - $tokenStorage, - $authenticationManager, - 'TheProviderKey' - ); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $method->invokeArgs($listener, [$request]); - } - - public function testGetPreAuthenticatedDataWithDifferentKeys() - { - $userCredentials = ['TheUser', null]; - - $request = new Request([], [], [], [], [], [ - 'TheUserKey' => 'TheUser', - ]); - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new RemoteUserAuthenticationListener( - $tokenStorage, - $authenticationManager, - 'TheProviderKey', - 'TheUserKey' - ); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $result = $method->invokeArgs($listener, [$request]); - $this->assertSame($result, $userCredentials); - } -} diff --git a/Tests/Firewall/SwitchUserListenerTest.php b/Tests/Firewall/SwitchUserListenerTest.php index 0338af00..114d0db9 100644 --- a/Tests/Firewall/SwitchUserListenerTest.php +++ b/Tests/Firewall/SwitchUserListenerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\Firewall; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -18,6 +19,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; @@ -32,17 +34,12 @@ class SwitchUserListenerTest extends TestCase { - private $tokenStorage; - - private $userProvider; - - private $userChecker; - - private $accessDecisionManager; - - private $request; - - private $event; + private TokenStorage $tokenStorage; + private InMemoryUserProvider $userProvider; + private MockObject&UserCheckerInterface $userChecker; + private MockObject&AccessDecisionManagerInterface $accessDecisionManager; + private Request $request; + private RequestEvent $event; protected function setUp(): void { @@ -72,22 +69,26 @@ public function testEventIsIgnoredIfUsernameIsNotPassedWithTheRequest() public function testExitUserThrowsAuthenticationExceptionIfNoCurrentToken() { - $this->expectException(AuthenticationCredentialsNotFoundException::class); $this->tokenStorage->setToken(null); $this->request->query->set('_switch_user', '_exit'); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + + $this->expectException(AuthenticationCredentialsNotFoundException::class); + $listener($this->event); } public function testExitUserThrowsAuthenticationExceptionIfOriginalTokenCannotBeFound() { - $this->expectException(AuthenticationCredentialsNotFoundException::class); $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', SwitchUserListener::EXIT_VALUE); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + + $this->expectException(AuthenticationCredentialsNotFoundException::class); + $listener($this->event); } @@ -127,9 +128,7 @@ public function testExitUserDispatchesEventWithRefreshedUser() ->expects($this->once()) ->method('dispatch') ->with( - $this->callback(function (SwitchUserEvent $event) use ($refreshedUser) { - return $event->getTargetUser() === $refreshedUser; - }), + $this->callback(fn (SwitchUserEvent $event) => $event->getTargetUser() === $refreshedUser), SecurityEvents::SWITCH_USER ) ; @@ -138,33 +137,8 @@ public function testExitUserDispatchesEventWithRefreshedUser() $listener($this->event); } - /** - * @group legacy - */ - public function testExitUserDoesNotDispatchEventWithStringUser() - { - $originalUser = 'anon.'; - $userProvider = $this->createMock(InMemoryUserProvider::class); - $userProvider - ->expects($this->never()) - ->method('refreshUser'); - $originalToken = new UsernamePasswordToken($originalUser, 'key'); - $this->tokenStorage->setToken(new SwitchUserToken('username', '', 'key', ['ROLE_USER'], $originalToken)); - $this->request->query->set('_switch_user', SwitchUserListener::EXIT_VALUE); - - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $dispatcher - ->expects($this->never()) - ->method('dispatch') - ; - - $listener = new SwitchUserListener($this->tokenStorage, $userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', $dispatcher); - $listener($this->event); - } - public function testSwitchUserIsDisallowed() { - $this->expectException(AccessDeniedException::class); $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_FOO']), 'key', ['ROLE_FOO']); $user = new InMemoryUser('username', 'password', []); @@ -176,12 +150,14 @@ public function testSwitchUserIsDisallowed() ->willReturn(false); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + + $this->expectException(AccessDeniedException::class); + $listener($this->event); } public function testSwitchUserTurnsAuthenticationExceptionTo403() { - $this->expectException(AccessDeniedException::class); $token = new UsernamePasswordToken(new InMemoryUser('username', '', ['ROLE_ALLOWED_TO_SWITCH']), 'key', ['ROLE_ALLOWED_TO_SWITCH']); $this->tokenStorage->setToken($token); @@ -191,6 +167,9 @@ public function testSwitchUserTurnsAuthenticationExceptionTo403() ->method('decide'); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + + $this->expectException(AccessDeniedException::class); + $listener($this->event); } @@ -202,11 +181,11 @@ public function testSwitchUser() $this->request->query->set('_switch_user', 'kuba'); $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); })) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) ->willReturn(true); $this->userChecker->expects($this->once()) - ->method('checkPostAuth')->with($this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); })); + ->method('checkPostAuth')->with($this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()), $token); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $listener($this->event); @@ -226,9 +205,12 @@ public function testSwitchUserAlreadySwitched() $this->request->query->set('_switch_user', 'kuba'); - $targetsUser = $this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); }); + $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); $this->accessDecisionManager->expects($this->once()) - ->method('decide')->with($originalToken, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) + ->method('decide')->with(self::callback(function (TokenInterface $token) use ($originalToken, $tokenStorage) { + // the token storage should also contain the original token for voters depending on it + return $token === $originalToken && $tokenStorage->getToken() === $originalToken; + }), ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) ->willReturn(true); $this->userChecker->expects($this->once()) @@ -258,7 +240,7 @@ public function testSwitchUserWorksWithFalsyUsernames() ->willReturn(true); $this->userChecker->expects($this->once()) - ->method('checkPostAuth')->with($this->callback(function ($argUser) use ($user) { return $user->isEqualTo($argUser); })); + ->method('checkPostAuth')->with($this->callback(fn ($argUser) => $user->isEqualTo($argUser))); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); $listener($this->event); @@ -279,7 +261,7 @@ public function testSwitchUserKeepsOtherQueryStringParameters() 'section' => 2, ]); - $targetsUser = $this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); }); + $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); $this->accessDecisionManager->expects($this->once()) ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) ->willReturn(true); @@ -306,7 +288,7 @@ public function testSwitchUserWithReplacedToken() $this->request->query->set('_switch_user', 'kuba'); $this->accessDecisionManager->expects($this->any()) - ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); })) + ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier())) ->willReturn(true); $dispatcher = $this->createMock(EventDispatcherInterface::class); @@ -333,10 +315,12 @@ public function testSwitchUserWithReplacedToken() public function testSwitchUserThrowsAuthenticationExceptionIfNoCurrentToken() { - $this->expectException(AuthenticationCredentialsNotFoundException::class); $this->tokenStorage->setToken(null); $this->request->query->set('_switch_user', 'username'); $listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager); + + $this->expectException(AuthenticationCredentialsNotFoundException::class); + $listener($this->event); } @@ -347,7 +331,7 @@ public function testSwitchUserStateless() $this->tokenStorage->setToken($token); $this->request->query->set('_switch_user', 'kuba'); - $targetsUser = $this->callback(function ($user) { return 'kuba' === $user->getUserIdentifier(); }); + $targetsUser = $this->callback(fn ($user) => 'kuba' === $user->getUserIdentifier()); $this->accessDecisionManager->expects($this->once()) ->method('decide')->with($token, ['ROLE_ALLOWED_TO_SWITCH'], $targetsUser) ->willReturn(true); @@ -381,9 +365,7 @@ public function testSwitchUserRefreshesOriginalToken() ->expects($this->once()) ->method('dispatch') ->with( - $this->callback(function (SwitchUserEvent $event) use ($refreshedOriginalUser) { - return $event->getToken()->getUser() === $refreshedOriginalUser; - }), + $this->callback(fn (SwitchUserEvent $event) => $event->getToken()->getUser() === $refreshedOriginalUser), SecurityEvents::SWITCH_USER ) ; diff --git a/Tests/Firewall/UsernamePasswordFormAuthenticationListenerTest.php b/Tests/Firewall/UsernamePasswordFormAuthenticationListenerTest.php deleted file mode 100644 index 063afc41..00000000 --- a/Tests/Firewall/UsernamePasswordFormAuthenticationListenerTest.php +++ /dev/null @@ -1,302 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\Session\SessionInterface; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Security; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; -use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; -use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler; -use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler; -use Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; - -/** - * @group legacy - */ -class UsernamePasswordFormAuthenticationListenerTest extends TestCase -{ - /** - * @dataProvider getUsernameForLength - */ - public function testHandleWhenUsernameLength(string $username, bool $ok) - { - $request = Request::create('/login_check', 'POST', ['_username' => $username, '_password' => 'symfony']); - $request->setSession($this->createMock(SessionInterface::class)); - - $httpUtils = $this->createMock(HttpUtils::class); - $httpUtils - ->expects($this->any()) - ->method('checkRequestPath') - ->willReturn(true) - ; - $httpUtils - ->method('createRedirectResponse') - ->willReturn(new RedirectResponse('/hello')) - ; - - $failureHandler = $this->createMock(AuthenticationFailureHandlerInterface::class); - $failureHandler - ->expects($ok ? $this->never() : $this->once()) - ->method('onAuthenticationFailure') - ->willReturn(new Response()) - ; - - $authenticationManager = $this->createMock(AuthenticationProviderManager::class); - $authenticationManager - ->expects($ok ? $this->once() : $this->never()) - ->method('authenticate') - ->willReturnArgument(0) - ; - - $listener = new UsernamePasswordFormAuthenticationListener( - $this->createMock(TokenStorageInterface::class), - $authenticationManager, - $this->createMock(SessionAuthenticationStrategyInterface::class), - $httpUtils, - 'TheProviderKey', - new DefaultAuthenticationSuccessHandler($httpUtils), - $failureHandler, - ['require_previous_session' => false] - ); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - /** - * @dataProvider postOnlyDataProvider - */ - public function testHandleNonStringUsernameWithArray(bool $postOnly) - { - $request = Request::create('/login_check', 'POST', ['_username' => []]); - $request->setSession($this->createMock(SessionInterface::class)); - $listener = new UsernamePasswordFormAuthenticationListener( - new TokenStorage(), - $this->createMock(AuthenticationManagerInterface::class), - new SessionAuthenticationStrategy(SessionAuthenticationStrategy::NONE), - $httpUtils = new HttpUtils(), - 'foo', - new DefaultAuthenticationSuccessHandler($httpUtils), - new DefaultAuthenticationFailureHandler($this->createMock(HttpKernelInterface::class), $httpUtils), - ['require_previous_session' => false, 'post_only' => $postOnly] - ); - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_username" must be a string, "array" given.'); - - $listener($event); - } - - /** - * @dataProvider postOnlyDataProvider - */ - public function testHandleNonStringUsernameWithInt(bool $postOnly) - { - $request = Request::create('/login_check', 'POST', ['_username' => 42]); - $request->setSession($this->createMock(SessionInterface::class)); - $listener = new UsernamePasswordFormAuthenticationListener( - new TokenStorage(), - $this->createMock(AuthenticationManagerInterface::class), - new SessionAuthenticationStrategy(SessionAuthenticationStrategy::NONE), - $httpUtils = new HttpUtils(), - 'foo', - new DefaultAuthenticationSuccessHandler($httpUtils), - new DefaultAuthenticationFailureHandler($this->createMock(HttpKernelInterface::class), $httpUtils), - ['require_previous_session' => false, 'post_only' => $postOnly] - ); - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_username" must be a string, "int" given.'); - - $listener($event); - } - - /** - * @dataProvider postOnlyDataProvider - */ - public function testHandleNonStringUsernameWithObject(bool $postOnly) - { - $request = Request::create('/login_check', 'POST', ['_username' => new \stdClass()]); - $request->setSession($this->createMock(SessionInterface::class)); - $listener = new UsernamePasswordFormAuthenticationListener( - new TokenStorage(), - $this->createMock(AuthenticationManagerInterface::class), - new SessionAuthenticationStrategy(SessionAuthenticationStrategy::NONE), - $httpUtils = new HttpUtils(), - 'foo', - new DefaultAuthenticationSuccessHandler($httpUtils), - new DefaultAuthenticationFailureHandler($this->createMock(HttpKernelInterface::class), $httpUtils), - ['require_previous_session' => false, 'post_only' => $postOnly] - ); - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "_username" must be a string, "stdClass" given.'); - - $listener($event); - } - - /** - * @dataProvider postOnlyDataProvider - */ - public function testHandleNonStringUsernameWithToString(bool $postOnly) - { - $usernameClass = $this->createMock(DummyUserClass::class); - $usernameClass - ->expects($this->atLeastOnce()) - ->method('__toString') - ->willReturn('someUsername'); - - $request = Request::create('/login_check', 'POST', ['_username' => $usernameClass, '_password' => 'symfony']); - $request->setSession($this->createMock(SessionInterface::class)); - $listener = new UsernamePasswordFormAuthenticationListener( - new TokenStorage(), - $this->createMock(AuthenticationManagerInterface::class), - new SessionAuthenticationStrategy(SessionAuthenticationStrategy::NONE), - $httpUtils = new HttpUtils(), - 'foo', - new DefaultAuthenticationSuccessHandler($httpUtils), - new DefaultAuthenticationFailureHandler($this->createMock(HttpKernelInterface::class), $httpUtils), - ['require_previous_session' => false, 'post_only' => $postOnly] - ); - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - $listener($event); - } - - /** - * @dataProvider postOnlyDataProvider - */ - public function testHandleWhenPasswordAreNull($postOnly) - { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The key "_password" cannot be null; check that the password field name of the form matches.'); - - $request = Request::create('/login_check', 'POST', ['_username' => 'symfony', 'password' => 'symfony']); - $request->setSession($this->createMock(SessionInterface::class)); - $listener = new UsernamePasswordFormAuthenticationListener( - new TokenStorage(), - $this->createMock(AuthenticationManagerInterface::class), - new SessionAuthenticationStrategy(SessionAuthenticationStrategy::NONE), - $httpUtils = new HttpUtils(), - 'foo', - new DefaultAuthenticationSuccessHandler($httpUtils), - new DefaultAuthenticationFailureHandler($this->createMock(HttpKernelInterface::class), $httpUtils), - ['require_previous_session' => false, 'post_only' => $postOnly] - ); - $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); - $listener($event); - } - - /** - * @dataProvider provideInvalidCsrfTokens - */ - public function testInvalidCsrfToken($invalidToken) - { - $formBody = ['_username' => 'fabien', '_password' => 'symfony']; - if (null !== $invalidToken) { - $formBody['_csrf_token'] = $invalidToken; - } - - $request = Request::create('/login_check', 'POST', $formBody); - $request->setSession($this->createMock(SessionInterface::class)); - - $httpUtils = $this->createMock(HttpUtils::class); - $httpUtils - ->method('checkRequestPath') - ->willReturn(true) - ; - $httpUtils - ->method('createRedirectResponse') - ->willReturn(new RedirectResponse('/hello')) - ; - - $failureHandler = $this->createMock(AuthenticationFailureHandlerInterface::class); - $failureHandler - ->expects($this->once()) - ->method('onAuthenticationFailure') - ->willReturn(new Response()) - ; - - $authenticationManager = $this->createMock(AuthenticationProviderManager::class); - $authenticationManager - ->expects($this->never()) - ->method('authenticate') - ; - - $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); - $csrfTokenManager->method('isTokenValid')->willReturn(false); - - $listener = new UsernamePasswordFormAuthenticationListener( - $this->createMock(TokenStorageInterface::class), - $authenticationManager, - $this->createMock(SessionAuthenticationStrategyInterface::class), - $httpUtils, - 'TheProviderKey', - new DefaultAuthenticationSuccessHandler($httpUtils), - $failureHandler, - ['require_previous_session' => false], - null, - null, - $csrfTokenManager - ); - - $listener(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); - } - - public static function postOnlyDataProvider(): array - { - return [ - [true], - [false], - ]; - } - - public static function getUsernameForLength(): array - { - return [ - [str_repeat('x', Security::MAX_USERNAME_LENGTH + 1), false], - [str_repeat('x', Security::MAX_USERNAME_LENGTH - 1), true], - ]; - } - - public static function provideInvalidCsrfTokens(): array - { - return [ - ['invalid'], - [['in' => 'valid']], - [null], - ]; - } -} - -class DummyUserClass -{ - public function __toString(): string - { - return ''; - } -} diff --git a/Tests/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php b/Tests/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php deleted file mode 100644 index e13a7362..00000000 --- a/Tests/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php +++ /dev/null @@ -1,271 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\Session\SessionInterface; -use Symfony\Component\HttpKernel\Event\RequestEvent; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\KernelInterface; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Security; -use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; -use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; -use Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Component\Translation\Loader\ArrayLoader; -use Symfony\Component\Translation\Translator; - -/** - * @author Kévin Dunglas - * - * @group legacy - */ -class UsernamePasswordJsonAuthenticationListenerTest extends TestCase -{ - /** - * @var UsernamePasswordJsonAuthenticationListener - */ - private $listener; - - private function createListener(array $options = [], $success = true, $matchCheckPath = true, $withMockedHandler = true) - { - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $httpUtils = $this->createMock(HttpUtils::class); - $httpUtils - ->expects($this->any()) - ->method('checkRequestPath') - ->willReturn($matchCheckPath) - ; - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $authenticatedToken = $this->createMock(TokenInterface::class); - - if ($success) { - $authenticationManager->method('authenticate')->willReturn($authenticatedToken); - } else { - $authenticationManager->method('authenticate')->willThrowException(new AuthenticationException()); - } - - $authenticationSuccessHandler = null; - $authenticationFailureHandler = null; - - if ($withMockedHandler) { - $authenticationSuccessHandler = $this->createMock(AuthenticationSuccessHandlerInterface::class); - $authenticationSuccessHandler->method('onAuthenticationSuccess')->willReturn(new Response('ok')); - $authenticationFailureHandler = $this->createMock(AuthenticationFailureHandlerInterface::class); - $authenticationFailureHandler->method('onAuthenticationFailure')->willReturn(new Response('ko')); - } - - $this->listener = new UsernamePasswordJsonAuthenticationListener($tokenStorage, $authenticationManager, $httpUtils, 'providerKey', $authenticationSuccessHandler, $authenticationFailureHandler, $options); - } - - public function testHandleSuccessIfRequestContentTypeIsJson() - { - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertEquals('ok', $event->getResponse()->getContent()); - } - - public function testSuccessIfRequestFormatIsJsonLD() - { - $this->createListener(); - $request = new Request([], [], [], [], [], [], '{"username": "dunglas", "password": "foo"}'); - $request->setRequestFormat('json-ld'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertEquals('ok', $event->getResponse()->getContent()); - } - - public function testHandleFailure() - { - $this->createListener([], false, true, false); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertSame(['error' => 'An authentication exception occurred.'], json_decode($event->getResponse()->getContent(), true)); - } - - public function testTranslatedHandleFailure() - { - $translator = new Translator('en'); - $translator->addLoader('array', new ArrayLoader()); - $translator->addResource('array', ['An authentication exception occurred.' => 'foo'], 'en', 'security'); - - $this->createListener([], false, true, false); - $this->listener->setTranslator($translator); - - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertSame(['error' => 'foo'], json_decode($event->getResponse()->getContent(), true)); - } - - public function testUsePath() - { - $this->createListener(['username_path' => 'user.login', 'password_path' => 'user.pwd']); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"user": {"login": "dunglas", "pwd": "foo"}}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertEquals('ok', $event->getResponse()->getContent()); - } - - public function testAttemptAuthenticationNoJson() - { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('Invalid JSON'); - $this->createListener(); - $request = new Request(); - $request->setRequestFormat('json'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - } - - public function testAttemptAuthenticationNoUsername() - { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "username" must be provided'); - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"usr": "dunglas", "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - } - - public function testAttemptAuthenticationNoPassword() - { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "password" must be provided'); - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "pass": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - } - - public function testAttemptAuthenticationUsernameNotAString() - { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "username" must be a string.'); - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": 1, "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - } - - public function testAttemptAuthenticationPasswordNotAString() - { - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('The key "password" must be a string.'); - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": 1}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - } - - public function testAttemptAuthenticationUsernameTooLong() - { - $this->createListener(); - $username = str_repeat('x', Security::MAX_USERNAME_LENGTH + 1); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], sprintf('{"username": "%s", "password": 1}', $username)); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertSame('ko', $event->getResponse()->getContent()); - } - - public function testDoesNotAttemptAuthenticationIfRequestPathDoesNotMatchCheckPath() - { - $this->createListener(['check_path' => '/'], true, false); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - $event->setResponse(new Response('original')); - - ($this->listener)($event); - $this->assertSame('original', $event->getResponse()->getContent()); - } - - public function testDoesNotAttemptAuthenticationIfRequestContentTypeIsNotJson() - { - $this->createListener(); - $request = new Request([], [], [], [], [], [], '{"username": "dunglas", "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - $event->setResponse(new Response('original')); - - ($this->listener)($event); - $this->assertSame('original', $event->getResponse()->getContent()); - } - - public function testAttemptAuthenticationIfRequestPathMatchesCheckPath() - { - $this->createListener(['check_path' => '/']); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertSame('ok', $event->getResponse()->getContent()); - } - - public function testNoErrorOnMissingSessionStrategy() - { - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); - $this->configurePreviousSession($request); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - ($this->listener)($event); - $this->assertEquals('ok', $event->getResponse()->getContent()); - } - - public function testMigratesViaSessionStrategy() - { - $this->createListener(); - $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); - $this->configurePreviousSession($request); - $event = new RequestEvent($this->createMock(KernelInterface::class), $request, KernelInterface::MAIN_REQUEST); - - $sessionStrategy = $this->createMock(SessionAuthenticationStrategyInterface::class); - $sessionStrategy->expects($this->once()) - ->method('onAuthentication') - ->with($request, $this->isInstanceOf(TokenInterface::class)); - $this->listener->setSessionAuthenticationStrategy($sessionStrategy); - - ($this->listener)($event); - $this->assertEquals('ok', $event->getResponse()->getContent()); - } - - private function configurePreviousSession(Request $request) - { - $session = $this->createMock(SessionInterface::class); - $session->expects($this->any()) - ->method('getName') - ->willReturn('test_session_name'); - $request->setSession($session); - $request->cookies->set('test_session_name', 'session_cookie_val'); - } -} diff --git a/Tests/Firewall/X509AuthenticationListenerTest.php b/Tests/Firewall/X509AuthenticationListenerTest.php deleted file mode 100644 index c0b3026d..00000000 --- a/Tests/Firewall/X509AuthenticationListenerTest.php +++ /dev/null @@ -1,130 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Firewall; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Http\Firewall\X509AuthenticationListener; - -/** - * @group legacy - */ -class X509AuthenticationListenerTest extends TestCase -{ - /** - * @dataProvider dataProviderGetPreAuthenticatedData - */ - public function testGetPreAuthenticatedData($user, $credentials) - { - $serverVars = []; - if ('' !== $user) { - $serverVars['SSL_CLIENT_S_DN_Email'] = $user; - } - if ('' !== $credentials) { - $serverVars['SSL_CLIENT_S_DN'] = $credentials; - } - - $request = new Request([], [], [], [], [], $serverVars); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new X509AuthenticationListener($tokenStorage, $authenticationManager, 'TheProviderKey'); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $result = $method->invokeArgs($listener, [$request]); - $this->assertSame($result, [$user, $credentials]); - } - - public static function dataProviderGetPreAuthenticatedData() - { - return [ - 'validValues' => ['TheUser', 'TheCredentials'], - 'noCredentials' => ['TheUser', ''], - ]; - } - - /** - * @dataProvider dataProviderGetPreAuthenticatedDataNoUser - */ - public function testGetPreAuthenticatedDataNoUser($emailAddress, $credentials) - { - $request = new Request([], [], [], [], [], ['SSL_CLIENT_S_DN' => $credentials]); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new X509AuthenticationListener($tokenStorage, $authenticationManager, 'TheProviderKey'); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $result = $method->invokeArgs($listener, [$request]); - $this->assertSame($result, [$emailAddress, $credentials]); - } - - public static function dataProviderGetPreAuthenticatedDataNoUser() - { - yield ['cert@example.com', 'CN=Sample certificate DN/emailAddress=cert@example.com']; - yield ['cert+something@example.com', 'CN=Sample certificate DN/emailAddress=cert+something@example.com']; - yield ['cert@example.com', 'CN=Sample certificate DN,emailAddress=cert@example.com']; - yield ['cert+something@example.com', 'CN=Sample certificate DN,emailAddress=cert+something@example.com']; - yield ['cert+something@example.com', 'emailAddress=cert+something@example.com,CN=Sample certificate DN']; - yield ['cert+something@example.com', 'emailAddress=cert+something@example.com']; - yield ['firstname.lastname@mycompany.co.uk', 'emailAddress=firstname.lastname@mycompany.co.uk,CN=Firstname.Lastname,OU=london,OU=company design and engineering,OU=Issuer London,OU=Roaming,OU=Interactive,OU=Users,OU=Standard,OU=Business,DC=england,DC=core,DC=company,DC=co,DC=uk']; - } - - public function testGetPreAuthenticatedDataNoData() - { - $this->expectException(BadCredentialsException::class); - $request = new Request([], [], [], [], [], []); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new X509AuthenticationListener($tokenStorage, $authenticationManager, 'TheProviderKey'); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $method->invokeArgs($listener, [$request]); - } - - public function testGetPreAuthenticatedDataWithDifferentKeys() - { - $userCredentials = ['TheUser', 'TheCredentials']; - - $request = new Request([], [], [], [], [], [ - 'TheUserKey' => 'TheUser', - 'TheCredentialsKey' => 'TheCredentials', - ]); - $tokenStorage = $this->createMock(TokenStorageInterface::class); - - $authenticationManager = $this->createMock(AuthenticationManagerInterface::class); - - $listener = new X509AuthenticationListener($tokenStorage, $authenticationManager, 'TheProviderKey', 'TheUserKey', 'TheCredentialsKey'); - - $method = new \ReflectionMethod($listener, 'getPreAuthenticatedData'); - $method->setAccessible(true); - - $result = $method->invokeArgs($listener, [$request]); - $this->assertSame($result, $userCredentials); - } -} diff --git a/Tests/FirewallMapTest.php b/Tests/FirewallMapTest.php index bc3a1a05..ee5b08b2 100644 --- a/Tests/FirewallMapTest.php +++ b/Tests/FirewallMapTest.php @@ -13,7 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; use Symfony\Component\Security\Http\Firewall\ExceptionListener; use Symfony\Component\Security\Http\FirewallMap; @@ -25,7 +25,7 @@ public function testGetListeners() $request = new Request(); - $notMatchingMatcher = $this->createMock(RequestMatcher::class); + $notMatchingMatcher = $this->createMock(RequestMatcherInterface::class); $notMatchingMatcher ->expects($this->once()) ->method('matches') @@ -35,7 +35,7 @@ public function testGetListeners() $map->add($notMatchingMatcher, [function () {}]); - $matchingMatcher = $this->createMock(RequestMatcher::class); + $matchingMatcher = $this->createMock(RequestMatcherInterface::class); $matchingMatcher ->expects($this->once()) ->method('matches') @@ -47,7 +47,7 @@ public function testGetListeners() $map->add($matchingMatcher, [$theListener], $theException); - $tooLateMatcher = $this->createMock(RequestMatcher::class); + $tooLateMatcher = $this->createMock(RequestMatcherInterface::class); $tooLateMatcher ->expects($this->never()) ->method('matches') @@ -67,7 +67,7 @@ public function testGetListenersWithAnEntryHavingNoRequestMatcher() $request = new Request(); - $notMatchingMatcher = $this->createMock(RequestMatcher::class); + $notMatchingMatcher = $this->createMock(RequestMatcherInterface::class); $notMatchingMatcher ->expects($this->once()) ->method('matches') @@ -82,7 +82,7 @@ public function testGetListenersWithAnEntryHavingNoRequestMatcher() $map->add(null, [$theListener], $theException); - $tooLateMatcher = $this->createMock(RequestMatcher::class); + $tooLateMatcher = $this->createMock(RequestMatcherInterface::class); $tooLateMatcher ->expects($this->never()) ->method('matches') @@ -102,7 +102,7 @@ public function testGetListenersWithNoMatchingEntry() $request = new Request(); - $notMatchingMatcher = $this->createMock(RequestMatcher::class); + $notMatchingMatcher = $this->createMock(RequestMatcherInterface::class); $notMatchingMatcher ->expects($this->once()) ->method('matches') diff --git a/Tests/FirewallTest.php b/Tests/FirewallTest.php index f9417d23..211269af 100644 --- a/Tests/FirewallTest.php +++ b/Tests/FirewallTest.php @@ -18,7 +18,9 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Security\Http\Firewall; +use Symfony\Component\Security\Http\Firewall\AbstractListener; use Symfony\Component\Security\Http\Firewall\ExceptionListener; +use Symfony\Component\Security\Http\Firewall\FirewallListenerInterface; use Symfony\Component\Security\Http\FirewallMapInterface; class FirewallTest extends TestCase @@ -97,4 +99,63 @@ public function testOnKernelRequestWithSubRequest() $this->assertFalse($event->hasResponse()); } + + public function testListenersAreCalled() + { + $calledListeners = []; + + $callableListener = static function () use (&$calledListeners) { $calledListeners[] = 'callableListener'; }; + $firewallListener = new class($calledListeners) implements FirewallListenerInterface { + public function __construct(private array &$calledListeners) + { + } + + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(RequestEvent $event): void + { + $this->calledListeners[] = 'firewallListener'; + } + + public static function getPriority(): int + { + return 0; + } + }; + $callableFirewallListener = new class($calledListeners) extends AbstractListener { + public function __construct(private array &$calledListeners) + { + } + + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(RequestEvent $event): void + { + $this->calledListeners[] = 'callableFirewallListener'; + } + }; + + $request = $this->createMock(Request::class); + + $map = $this->createMock(FirewallMapInterface::class); + $map + ->expects($this->once()) + ->method('getListeners') + ->with($this->equalTo($request)) + ->willReturn([[$callableListener, $firewallListener, $callableFirewallListener], null, null]) + ; + + $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); + + $firewall = new Firewall($map, $this->createMock(EventDispatcherInterface::class)); + $firewall->onKernelRequest($event); + + $this->assertSame(['callableListener', 'firewallListener', 'callableFirewallListener'], $calledListeners); + } } diff --git a/Tests/Fixtures/CustomUser.php b/Tests/Fixtures/CustomUser.php new file mode 100644 index 00000000..9d6e29e6 --- /dev/null +++ b/Tests/Fixtures/CustomUser.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +final class CustomUser implements UserInterface, PasswordAuthenticatedUserInterface +{ + public function __construct( + private string $username, + private array $roles, + private ?string $password, + private ?bool $hashPassword, + ) { + } + + public function getUserIdentifier(): string + { + return $this->username; + } + + public function getRoles(): array + { + return $this->roles; + } + + public function getPassword(): ?string + { + return $this->password ?? null; + } + + public function eraseCredentials(): void + { + } + + public function __serialize(): array + { + $data = (array) $this; + $passwordKey = \sprintf("\0%s\0password", self::class); + + if ($this->hashPassword) { + $data[$passwordKey] = hash('crc32c', $this->password); + } elseif (null !== $this->hashPassword) { + unset($data[$passwordKey]); + } + + return $data; + } +} diff --git a/Tests/Fixtures/DummyAuthenticator.php b/Tests/Fixtures/DummyAuthenticator.php index 0b221813..6e9b6174 100644 --- a/Tests/Fixtures/DummyAuthenticator.php +++ b/Tests/Fixtures/DummyAuthenticator.php @@ -46,8 +46,4 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio { return null; } - - public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface - { - } } diff --git a/Tests/Fixtures/IsCsrfTokenValidAttributeController.php b/Tests/Fixtures/IsCsrfTokenValidAttributeController.php new file mode 100644 index 00000000..4fa65423 --- /dev/null +++ b/Tests/Fixtures/IsCsrfTokenValidAttributeController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; + +#[IsCsrfTokenValid('foo')] +class IsCsrfTokenValidAttributeController +{ + public function __invoke() + { + } +} diff --git a/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php b/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php new file mode 100644 index 00000000..8555a43b --- /dev/null +++ b/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; + +class IsCsrfTokenValidAttributeMethodsController +{ + public function noAttribute() + { + } + + #[IsCsrfTokenValid('foo')] + public function withDefaultTokenKey() + { + } + + #[IsCsrfTokenValid(new Expression('"foo_" ~ args.id'))] + public function withCustomExpressionId(string $id) + { + } + + #[IsCsrfTokenValid(new Expression('"foo_" ~ args.slug'))] + public function withInvalidExpressionId(string $id) + { + } + + #[IsCsrfTokenValid('foo', tokenKey: 'my_token_key')] + public function withCustomTokenKey() + { + } + + #[IsCsrfTokenValid('foo', tokenKey: 'invalid_token_key')] + public function withInvalidTokenKey() + { + } + + #[IsCsrfTokenValid('foo', methods: 'DELETE')] + public function withDeleteMethod() + { + } + + #[IsCsrfTokenValid('foo', methods: ['GET', 'POST'])] + public function withGetOrPostMethod() + { + } + + #[IsCsrfTokenValid('foo', tokenKey: 'invalid_token_key', methods: ['POST'])] + public function withPostMethodAndInvalidTokenKey() + { + } +} diff --git a/Tests/Fixtures/IsGrantedAttributeController.php b/Tests/Fixtures/IsGrantedAttributeController.php new file mode 100644 index 00000000..5cc915ec --- /dev/null +++ b/Tests/Fixtures/IsGrantedAttributeController.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsGranted; + +#[IsGranted(attribute: 'ROLE_USER')] +class IsGrantedAttributeController +{ + #[IsGranted(attribute: 'ROLE_ADMIN')] + public function foo() + { + } + + public function bar() + { + } +} diff --git a/Tests/Fixtures/IsGrantedAttributeMethodsController.php b/Tests/Fixtures/IsGrantedAttributeMethodsController.php new file mode 100644 index 00000000..f4e54704 --- /dev/null +++ b/Tests/Fixtures/IsGrantedAttributeMethodsController.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +class IsGrantedAttributeMethodsController +{ + public function noAttribute() + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN')] + public function admin() + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN', subject: 'arg2Name')] + public function withSubject($arg1Name, $arg2Name) + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN', subject: ['arg1Name', 'arg2Name'])] + public function withSubjectArray($arg1Name, $arg2Name) + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN', subject: 'non_existent')] + public function withMissingSubject() + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN', message: 'Not found', statusCode: 404)] + public function notFound() + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN', message: 'Exception Code Http', statusCode: 404, exceptionCode: 10010)] + public function exceptionCodeInHttpException() + { + } + + #[IsGranted(attribute: 'ROLE_ADMIN', message: 'Exception Code Access Denied', exceptionCode: 10010)] + public function exceptionCodeInAccessDeniedException() + { + } + + #[IsGranted(attribute: new Expression('"ROLE_ADMIN" in role_names or is_granted("POST_VIEW", subject)'), subject: 'post')] + public function withExpressionInAttribute($post) + { + } + + #[IsGranted(attribute: new Expression('user === subject'), subject: new Expression('args["post"].getAuthor()'))] + public function withExpressionInSubject($post) + { + } + + #[IsGranted(attribute: new Expression('user === subject["author"]'), subject: [ + 'author' => new Expression('args["post"].getAuthor()'), + 'alias' => 'arg2Name', + ])] + public function withNestedExpressionInSubject($post, $arg2Name) + { + } + + #[IsGranted(attribute: 'SOME_VOTER', subject: new Expression('request'))] + public function withRequestAsSubject() + { + } +} diff --git a/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php b/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php new file mode 100644 index 00000000..bf001c53 --- /dev/null +++ b/Tests/Fixtures/IsGrantedAttributeMethodsWithClosureController.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGrantedContext; + +class IsGrantedAttributeMethodsWithClosureController +{ + public function noAttribute() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + })] + public function admin() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, subject: 'arg2Name')] + public function withSubject($arg1Name, $arg2Name) + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, subject: ['arg1Name', 'arg2Name'])] + public function withSubjectArray($arg1Name, $arg2Name) + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, subject: 'non_existent')] + public function withMissingSubject() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, message: 'Not found', statusCode: 404)] + public function notFound() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, message: 'Exception Code Http', statusCode: 404, exceptionCode: 10010)] + public function exceptionCodeInHttpException() + { + } + + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + }, message: 'Exception Code Access Denied', exceptionCode: 10010)] + public function exceptionCodeInAccessDeniedException() + { + } + + #[IsGranted( + static function (IsGrantedContext $context, mixed $subject) { + return $context->user === $subject; + }, + subject: static function (array $args) { + return $args['post']; + } + )] + public function withClosureAsSubject($post) + { + } + + #[IsGranted( + static function (IsGrantedContext $context, array $subject) { + return $context->user === $subject['author']; + }, + subject: static function (array $args) { + return [ + 'author' => $args['post'], + 'alias' => 'bar', + ]; + } + )] + public function withNestArgsInSubject($post, $arg2Name) + { + } +} diff --git a/Tests/Fixtures/IsGrantedAttributeWithClosureController.php b/Tests/Fixtures/IsGrantedAttributeWithClosureController.php new file mode 100644 index 00000000..61a1ddbc --- /dev/null +++ b/Tests/Fixtures/IsGrantedAttributeWithClosureController.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGrantedContext; + +#[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_USER'); +})] +class IsGrantedAttributeWithClosureController +{ + #[IsGranted(static function (IsGrantedContext $context) { + return $context->isGranted('ROLE_ADMIN'); + })] + public function foo() + { + } + + public function bar() + { + } +} diff --git a/Tests/Fixtures/NullUserToken.php b/Tests/Fixtures/NullUserToken.php new file mode 100644 index 00000000..95048e46 --- /dev/null +++ b/Tests/Fixtures/NullUserToken.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; +use Symfony\Component\Security\Core\User\UserInterface; + +class NullUserToken extends AbstractToken +{ + public function getUser(): ?UserInterface + { + return null; + } +} diff --git a/Tests/HttpUtilsTest.php b/Tests/HttpUtilsTest.php index a63c40f5..ccb538f9 100644 --- a/Tests/HttpUtilsTest.php +++ b/Tests/HttpUtilsTest.php @@ -20,8 +20,8 @@ use Symfony\Component\Routing\Matcher\RequestMatcherInterface; use Symfony\Component\Routing\Matcher\UrlMatcherInterface; use Symfony\Component\Routing\RequestContext; -use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\SecurityRequestAttributes; class HttpUtilsTest extends TestCase { @@ -211,9 +211,9 @@ public function testCreateRequestPassesSessionToTheNewRequest() } /** - * @dataProvider provideSecurityContextAttributes + * @dataProvider provideSecurityRequestAttributes */ - public function testCreateRequestPassesSecurityContextAttributesToTheNewRequest($attribute) + public function testCreateRequestPassesSecurityRequestAttributesToTheNewRequest($attribute) { $request = $this->getRequest(); $request->attributes->set($attribute, 'foo'); @@ -224,12 +224,12 @@ public function testCreateRequestPassesSecurityContextAttributesToTheNewRequest( $this->assertSame('foo', $subRequest->attributes->get($attribute)); } - public static function provideSecurityContextAttributes() + public static function provideSecurityRequestAttributes() { return [ - [Security::AUTHENTICATION_ERROR], - [Security::ACCESS_DENIED_ERROR], - [Security::LAST_USERNAME], + [SecurityRequestAttributes::AUTHENTICATION_ERROR], + [SecurityRequestAttributes::ACCESS_DENIED_ERROR], + [SecurityRequestAttributes::LAST_USERNAME], ]; } @@ -306,7 +306,6 @@ public function testCheckRequestPathWithUrlMatcherAndResourceFoundByRequest() public function testCheckRequestPathWithUrlMatcherLoadingException() { - $this->expectException(\RuntimeException::class); $urlMatcher = $this->createMock(UrlMatcherInterface::class); $urlMatcher ->expects($this->any()) @@ -315,6 +314,9 @@ public function testCheckRequestPathWithUrlMatcherLoadingException() ; $utils = new HttpUtils(null, $urlMatcher); + + $this->expectException(\RuntimeException::class); + $utils->checkRequestPath($this->getRequest(), 'foobar'); } @@ -347,13 +349,6 @@ public function testCheckPathWithoutRouteParam() $this->assertFalse($utils->checkRequestPath($this->getRequest(), 'path/index.html')); } - public function testUrlMatcher() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Matcher must either implement UrlMatcherInterface or RequestMatcherInterface'); - new HttpUtils($this->getUrlGenerator(), new \stdClass()); - } - public function testGenerateUriRemovesQueryString() { $utils = new HttpUtils($this->getUrlGenerator('/foo/bar')); @@ -376,8 +371,7 @@ public function testUrlGeneratorIsRequiredToGenerateUrl() { $this->expectException(\LogicException::class); $this->expectExceptionMessage('You must provide a UrlGeneratorInterface instance to be able to use routes.'); - $utils = new HttpUtils(); - $utils->generateUri(new Request(), 'route_name'); + (new HttpUtils())->generateUri(new Request(), 'route_name'); } private function getUrlGenerator($generatedUrl = '/foo/bar') diff --git a/Tests/LoginLink/LoginLinkHandlerTest.php b/Tests/LoginLink/LoginLinkHandlerTest.php index ee5952e6..06832333 100644 --- a/Tests/LoginLink/LoginLinkHandlerTest.php +++ b/Tests/LoginLink/LoginLinkHandlerTest.php @@ -32,16 +32,11 @@ class LoginLinkHandlerTest extends TestCase { - /** @var MockObject|UrlGeneratorInterface */ - private $router; - /** @var TestLoginLinkHandlerUserProvider */ - private $userProvider; - /** @var PropertyAccessorInterface */ - private $propertyAccessor; - /** @var MockObject|ExpiredSignatureStorage */ - private $expiredLinkStorage; - /** @var CacheItemPoolInterface */ - private $expiredLinkCache; + private MockObject&UrlGeneratorInterface $router; + private TestLoginLinkHandlerUserProvider $userProvider; + private PropertyAccessorInterface $propertyAccessor; + private ExpiredSignatureStorage $expiredLinkStorage; + private CacheItemPoolInterface $expiredLinkCache; protected function setUp(): void { @@ -63,15 +58,14 @@ public function testCreateLoginLink($user, array $extraProperties, ?Request $req ->method('generate') ->with( 'app_check_login_link_route', - $this->callback(function ($parameters) use ($extraProperties) { - return 'weaverryan' == $parameters['user'] - && isset($parameters['expires']) - && isset($parameters['hash']) - // allow a small expiration offset to avoid time-sensitivity - && abs(time() + 600 - $parameters['expires']) <= 1 - // make sure hash is what we expect - && $parameters['hash'] === $this->createSignatureHash('weaverryan', $parameters['expires'], $extraProperties); - }), + $this->callback(fn ($parameters) => 'weaverryan' === $parameters['user'] + && isset($parameters['expires']) + && isset($parameters['hash']) + // allow a small expiration offset to avoid time-sensitivity + && abs(time() + 600 - $parameters['expires']) <= 1 + // make sure hash is what we expect + && $parameters['hash'] === $this->createSignatureHash('weaverryan', $parameters['expires'], $extraProperties) + ), UrlGeneratorInterface::ABSOLUTE_URL ) ->willReturn('https://example.com/login/verify?user=weaverryan&hash=abchash&expires=1601235000'); @@ -122,16 +116,47 @@ public static function provideCreateLoginLinkData() ]; yield [ - new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash', new \DateTime('2020-06-01 00:00:00', new \DateTimeZone('+0000'))), + new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash', new \DateTimeImmutable('2020-06-01 00:00:00', new \DateTimeZone('+0000'))), ['lastAuthenticatedAt' => '2020-06-01T00:00:00+00:00'], ]; } + public function testCreateLoginLinkWithLifetime() + { + $extraProperties = ['emailProperty' => 'ryan@symfonycasts.com', 'passwordProperty' => 'pwhash']; + + $this->router->expects($this->once()) + ->method('generate') + ->with( + 'app_check_login_link_route', + $this->callback(fn ($parameters) => 'weaverryan' === $parameters['user'] + && isset($parameters['expires']) + // allow a small expiration offset to avoid time-sensitivity + && abs(time() + 1000 - $parameters['expires']) <= 1 + && isset($parameters['hash']) + // make sure hash is what we expect + && $parameters['hash'] === $this->createSignatureHash('weaverryan', $parameters['expires'], $extraProperties) + ), + UrlGeneratorInterface::ABSOLUTE_URL + ) + ->willReturn('https://example.com/login/verify?user=weaverryan&hash=abchash&expires=1654244256'); + + $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); + $lifetime = 1000; + + $loginLink = $this->createLinker([], array_keys($extraProperties))->createLoginLink( + user: $user, + lifetime: $lifetime, + ); + + $this->assertSame('https://example.com/login/verify?user=weaverryan&hash=abchash&expires=1654244256', $loginLink->getUrl()); + } + public function testConsumeLoginLink() { $expires = time() + 500; $signature = $this->createSignatureHash('weaverryan', $expires); - $request = Request::create(sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d', $signature, $expires)); + $request = Request::create(\sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d', $signature, $expires)); $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); $this->userProvider->createUser($user); @@ -148,7 +173,7 @@ public function testConsumeLoginLinkWithExpired() { $expires = time() - 500; $signature = $this->createSignatureHash('weaverryan', $expires); - $request = Request::create(sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d', $signature, $expires)); + $request = Request::create(\sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d', $signature, $expires)); $linker = $this->createLinker(['max_uses' => 3]); $this->expectException(ExpiredLoginLinkException::class); @@ -166,7 +191,7 @@ public function testConsumeLoginLinkWithUserNotFound() public function testConsumeLoginLinkWithDifferentSignature() { - $request = Request::create(sprintf('/login/verify?user=weaverryan&hash=fake_hash&expires=%d', time() + 500)); + $request = Request::create(\sprintf('/login/verify?user=weaverryan&hash=fake_hash&expires=%d', time() + 500)); $linker = $this->createLinker(); $this->expectException(InvalidLoginLinkException::class); @@ -177,7 +202,7 @@ public function testConsumeLoginLinkExceedsMaxUsage() { $expires = time() + 500; $signature = $this->createSignatureHash('weaverryan', $expires); - $request = Request::create(sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d', $signature, $expires)); + $request = Request::create(\sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d', $signature, $expires)); $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); $this->userProvider->createUser($user); @@ -215,6 +240,30 @@ public function testConsumeLoginLinkWithMissingExpiration() $linker->consumeLoginLink($request); } + public function testConsumeLoginLinkWithInvalidExpiration() + { + $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); + $this->userProvider->createUser($user); + + $this->expectException(InvalidLoginLinkException::class); + $request = Request::create('/login/verify?user=weaverryan&hash=thehash&expires=%E2%80%AA1000000000%E2%80%AC'); + + $linker = $this->createLinker(); + $linker->consumeLoginLink($request); + } + + public function testConsumeLoginLinkWithInvalidHash() + { + $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); + $this->userProvider->createUser($user); + + $this->expectException(InvalidLoginLinkException::class); + $request = Request::create('/login/verify?user=weaverryan&hash[]=an&hash[]=array&expires=1000000000'); + + $linker = $this->createLinker(); + $linker->consumeLoginLink($request); + } + private function createSignatureHash(string $username, int $expires, array $extraFields = ['emailProperty' => 'ryan@symfonycasts.com', 'passwordProperty' => 'pwhash']): string { $hasher = new SignatureHasher($this->propertyAccessor, array_keys($extraFields), 's3cret'); @@ -236,10 +285,10 @@ private function createLinker(array $options = [], array $extraProperties = ['em class TestLoginLinkHandlerUser implements UserInterface { - public $username; - public $emailProperty; - public $passwordProperty; - public $lastAuthenticatedAt; + public string $username; + public string $emailProperty; + public string $passwordProperty; + public \DateTimeImmutable|string|null $lastAuthenticatedAt; public function __construct($username, $emailProperty, $passwordProperty, $lastAuthenticatedAt = null) { @@ -259,21 +308,12 @@ public function getPassword(): string return $this->passwordProperty; } - public function getSalt(): string - { - return ''; - } - - public function getUsername(): string - { - return $this->username; - } - public function getUserIdentifier(): string { return $this->username; } + #[\Deprecated] public function eraseCredentials(): void { } @@ -281,7 +321,7 @@ public function eraseCredentials(): void class TestLoginLinkHandlerUserProvider implements UserProviderInterface { - private $users = []; + private array $users = []; public function createUser(TestLoginLinkHandlerUser $user): void { diff --git a/Tests/Logout/CookieClearingLogoutHandlerTest.php b/Tests/Logout/CookieClearingLogoutHandlerTest.php deleted file mode 100644 index f9bcc99a..00000000 --- a/Tests/Logout/CookieClearingLogoutHandlerTest.php +++ /dev/null @@ -1,59 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Logout; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Http\Logout\CookieClearingLogoutHandler; - -/** - * @group legacy - */ -class CookieClearingLogoutHandlerTest extends TestCase -{ - public function testLogout() - { - $request = new Request(); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - - $handler = new CookieClearingLogoutHandler(['foo' => ['path' => '/foo', 'domain' => 'foo.foo', 'secure' => true, 'samesite' => Cookie::SAMESITE_STRICT], 'foo2' => ['path' => null, 'domain' => null]]); - - $cookies = $response->headers->getCookies(); - $this->assertCount(0, $cookies); - - $handler->logout($request, $response, $token); - - $cookies = $response->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY); - $this->assertCount(2, $cookies); - - $cookie = $cookies['foo.foo']['/foo']['foo']; - $this->assertEquals('foo', $cookie->getName()); - $this->assertEquals('/foo', $cookie->getPath()); - $this->assertEquals('foo.foo', $cookie->getDomain()); - $this->assertEquals(Cookie::SAMESITE_STRICT, $cookie->getSameSite()); - $this->assertTrue($cookie->isSecure()); - $this->assertTrue($cookie->isCleared()); - - $cookie = $cookies['']['/']['foo2']; - $this->assertStringStartsWith('foo2', $cookie->getName()); - $this->assertEquals('/', $cookie->getPath()); - $this->assertNull($cookie->getDomain()); - $this->assertNull($cookie->getSameSite()); - $this->assertFalse($cookie->isSecure()); - $this->assertTrue($cookie->isCleared()); - } -} diff --git a/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php b/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php deleted file mode 100644 index a11d2650..00000000 --- a/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Logout; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\Session\Session; -use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage; -use Symfony\Component\Security\Http\Logout\CsrfTokenClearingLogoutHandler; - -/** - * @group legacy - */ -class CsrfTokenClearingLogoutHandlerTest extends TestCase -{ - private $session; - private $requestStack; - private $csrfTokenStorage; - private $csrfTokenClearingLogoutHandler; - - protected function setUp(): void - { - $this->session = new Session(new MockArraySessionStorage()); - - // BC for symfony/security-core < 5.3 - if (method_exists(SessionTokenStorage::class, 'getSession')) { - $request = new Request(); - $request->setSession($this->session); - $this->requestStack = new RequestStack(); - $this->requestStack->push($request); - } - - $this->csrfTokenStorage = new SessionTokenStorage($this->requestStack ?? $this->session, 'foo'); - $this->csrfTokenStorage->setToken('foo', 'bar'); - $this->csrfTokenStorage->setToken('foobar', 'baz'); - $this->csrfTokenClearingLogoutHandler = new CsrfTokenClearingLogoutHandler($this->csrfTokenStorage); - } - - public function testCsrfTokenCookieWithSameNamespaceIsRemoved() - { - $this->assertSame('bar', $this->session->get('foo/foo')); - $this->assertSame('baz', $this->session->get('foo/foobar')); - - $this->csrfTokenClearingLogoutHandler->logout(new Request(), new Response(), $this->createMock(TokenInterface::class)); - - $this->assertFalse($this->csrfTokenStorage->hasToken('foo')); - $this->assertFalse($this->csrfTokenStorage->hasToken('foobar')); - - $this->assertFalse($this->session->has('foo/foo')); - $this->assertFalse($this->session->has('foo/foobar')); - } - - public function testCsrfTokenCookieWithDifferentNamespaceIsNotRemoved() - { - $barNamespaceCsrfSessionStorage = new SessionTokenStorage($this->requestStack ?? $this->session, 'bar'); - $barNamespaceCsrfSessionStorage->setToken('foo', 'bar'); - $barNamespaceCsrfSessionStorage->setToken('foobar', 'baz'); - - $this->assertSame('bar', $this->session->get('foo/foo')); - $this->assertSame('baz', $this->session->get('foo/foobar')); - $this->assertSame('bar', $this->session->get('bar/foo')); - $this->assertSame('baz', $this->session->get('bar/foobar')); - - $this->csrfTokenClearingLogoutHandler->logout(new Request(), new Response(), $this->createMock(TokenInterface::class)); - - $this->assertTrue($barNamespaceCsrfSessionStorage->hasToken('foo')); - $this->assertTrue($barNamespaceCsrfSessionStorage->hasToken('foobar')); - $this->assertSame('bar', $barNamespaceCsrfSessionStorage->getToken('foo')); - $this->assertSame('baz', $barNamespaceCsrfSessionStorage->getToken('foobar')); - $this->assertFalse($this->csrfTokenStorage->hasToken('foo')); - $this->assertFalse($this->csrfTokenStorage->hasToken('foobar')); - - $this->assertFalse($this->session->has('foo/foo')); - $this->assertFalse($this->session->has('foo/foobar')); - $this->assertSame('bar', $this->session->get('bar/foo')); - $this->assertSame('baz', $this->session->get('bar/foobar')); - } -} diff --git a/Tests/Logout/DefaultLogoutSuccessHandlerTest.php b/Tests/Logout/DefaultLogoutSuccessHandlerTest.php deleted file mode 100644 index 45b2794f..00000000 --- a/Tests/Logout/DefaultLogoutSuccessHandlerTest.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Logout; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Logout\DefaultLogoutSuccessHandler; - -/** - * @group legacy - */ -class DefaultLogoutSuccessHandlerTest extends TestCase -{ - public function testLogout() - { - $request = $this->createMock(Request::class); - $response = new RedirectResponse('/dashboard'); - - $httpUtils = $this->createMock(HttpUtils::class); - $httpUtils->expects($this->once()) - ->method('createRedirectResponse') - ->with($request, '/dashboard') - ->willReturn($response); - - $handler = new DefaultLogoutSuccessHandler($httpUtils, '/dashboard'); - $result = $handler->onLogoutSuccess($request); - - $this->assertSame($response, $result); - } -} diff --git a/Tests/Logout/LogoutUrlGeneratorTest.php b/Tests/Logout/LogoutUrlGeneratorTest.php index 65992131..3cd4b69e 100644 --- a/Tests/Logout/LogoutUrlGeneratorTest.php +++ b/Tests/Logout/LogoutUrlGeneratorTest.php @@ -14,7 +14,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -25,16 +24,13 @@ */ class LogoutUrlGeneratorTest extends TestCase { - /** @var TokenStorage */ - private $tokenStorage; - /** @var LogoutUrlGenerator */ - private $generator; + private TokenStorage $tokenStorage; + private LogoutUrlGenerator $generator; protected function setUp(): void { - $requestStack = $this->createMock(RequestStack::class); - $request = $this->createMock(Request::class); - $requestStack->method('getCurrentRequest')->willReturn($request); + $requestStack = new RequestStack(); + $requestStack->push(new Request()); $this->tokenStorage = new TokenStorage(); $this->generator = new LogoutUrlGenerator($requestStack, null, $this->tokenStorage); @@ -49,9 +45,10 @@ public function testGetLogoutPath() public function testGetLogoutPathWithoutLogoutListenerRegisteredForKeyThrowsException() { + $this->generator->registerListener('secured_area', '/logout', null, null, null); + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No LogoutListener found for firewall key "unregistered_key".'); - $this->generator->registerListener('secured_area', '/logout', null, null, null); $this->generator->getLogoutPath('unregistered_key'); } @@ -64,18 +61,6 @@ public function testGuessFromToken() $this->assertSame('/logout', $this->generator->getLogoutPath()); } - /** - * @group legacy - */ - public function testGuessFromAnonymousTokenThrowsException() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to generate a logout url for an anonymous token.'); - $this->tokenStorage->setToken(new AnonymousToken('default', 'anon.')); - - $this->generator->getLogoutPath(); - } - public function testGuessFromCurrentFirewallKey() { $this->generator->registerListener('secured_area', '/logout', null, null); @@ -101,11 +86,23 @@ public function testGuessFromTokenWithoutFirewallNameFallbacksToCurrentFirewall( $this->assertSame('/logout', $this->generator->getLogoutPath()); } - public function testUnableToGuessThrowsException() + public function testUnableToGuessWithoutCurrentFirewallThrowsException() { + $this->generator->registerListener('secured_area', '/logout', null, null); + $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to find the current firewall LogoutListener, please provide the provider key manually'); + $this->expectExceptionMessage('This request is not behind a firewall, pass the firewall name manually to generate a logout URL.'); + + $this->generator->getLogoutPath(); + } + + public function testUnableToGuessWithCurrentFirewallThrowsException() + { $this->generator->registerListener('secured_area', '/logout', null, null); + $this->generator->setCurrentFirewall('admin'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to find logout in the current firewall, pass the firewall name manually to generate a logout URL.'); $this->generator->getLogoutPath(); } diff --git a/Tests/Logout/SessionLogoutHandlerTest.php b/Tests/Logout/SessionLogoutHandlerTest.php deleted file mode 100644 index 182a18bd..00000000 --- a/Tests/Logout/SessionLogoutHandlerTest.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\Logout; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\Session\Session; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Http\Logout\SessionLogoutHandler; - -/** - * @group legacy - */ -class SessionLogoutHandlerTest extends TestCase -{ - public function testLogout() - { - $handler = new SessionLogoutHandler(); - - $request = $this->createMock(Request::class); - $response = new Response(); - $session = $this->createMock(Session::class); - - $request - ->expects($this->once()) - ->method('getSession') - ->willReturn($session) - ; - - $session - ->expects($this->once()) - ->method('invalidate') - ; - - $handler->logout($request, $response, $this->createMock(TokenInterface::class)); - } -} diff --git a/Tests/RememberMe/AbstractRememberMeServicesTest.php b/Tests/RememberMe/AbstractRememberMeServicesTest.php deleted file mode 100644 index 825ef808..00000000 --- a/Tests/RememberMe/AbstractRememberMeServicesTest.php +++ /dev/null @@ -1,329 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\RememberMe; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Core\User\UserProviderInterface; -use Symfony\Component\Security\Http\RememberMe\AbstractRememberMeServices; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; - -/** - * @group legacy - */ -class AbstractRememberMeServicesTest extends TestCase -{ - public function testGetRememberMeParameter() - { - $service = $this->getService(null, ['remember_me_parameter' => 'foo']); - - $this->assertEquals('foo', $service->getRememberMeParameter()); - } - - public function testGetSecret() - { - $service = $this->getService(); - $this->assertEquals('foosecret', $service->getSecret()); - } - - public function testAutoLoginReturnsNullWhenNoCookie() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - - $this->assertNull($service->autoLogin(new Request())); - } - - public function testAutoLoginReturnsNullAfterLoginFail() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - - $request = new Request(); - $request->cookies->set('foo', 'foo'); - - $service->loginFail($request); - $this->assertNull($service->autoLogin($request)); - } - - public function testAutoLoginThrowsExceptionWhenImplementationDoesNotReturnUserInterface() - { - $this->expectException(\RuntimeException::class); - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - $request->cookies->set('foo', 'foo'); - - $service - ->expects($this->once()) - ->method('processAutoLoginCookie') - ->willReturn(null) - ; - - $service->autoLogin($request); - } - - public function testAutoLogin() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - $request->cookies->set('foo', 'foo'); - - $user = $this->createMock(UserInterface::class); - $user - ->expects($this->once()) - ->method('getRoles') - ->willReturn([]) - ; - - $service - ->expects($this->once()) - ->method('processAutoLoginCookie') - ->willReturn($user) - ; - - $returnedToken = $service->autoLogin($request); - - $this->assertSame($user, $returnedToken->getUser()); - $this->assertSame('foosecret', $returnedToken->getSecret()); - $this->assertSame('fookey', $returnedToken->getFirewallName()); - } - - /** - * @dataProvider provideOptionsForLogout - */ - public function testLogout(array $options) - { - $service = $this->getService(null, $options); - $request = new Request(); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - $service->logout($request, $response, $token); - $cookie = $request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME); - $this->assertInstanceOf(Cookie::class, $cookie); - $this->assertTrue($cookie->isCleared()); - $this->assertSame($options['name'], $cookie->getName()); - $this->assertSame($options['path'], $cookie->getPath()); - $this->assertSame($options['domain'], $cookie->getDomain()); - $this->assertSame($options['secure'], $cookie->isSecure()); - $this->assertSame($options['httponly'], $cookie->isHttpOnly()); - } - - public static function provideOptionsForLogout() - { - return [ - [['name' => 'foo', 'path' => '/', 'domain' => null, 'secure' => false, 'httponly' => true]], - [['name' => 'foo', 'path' => '/bar', 'domain' => 'baz.com', 'secure' => true, 'httponly' => false]], - ]; - } - - public function testLoginFail() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - - $service->loginFail($request); - - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testLoginSuccessIsNotProcessedWhenTokenDoesNotContainUserInterfaceImplementation() - { - $service = $this->getService(null, ['name' => 'foo', 'always_remember_me' => true, 'path' => null, 'domain' => null]); - $request = new Request(); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->once()) - ->method('getUser') - ->willReturn('foo') - ; - - $service - ->expects($this->never()) - ->method('onLoginSuccess') - ; - - $this->assertFalse($request->request->has('foo')); - - $service->loginSuccess($request, $response, $token); - } - - public function testLoginSuccessIsNotProcessedWhenRememberMeIsNotRequested() - { - $service = $this->getService(null, ['name' => 'foo', 'always_remember_me' => false, 'remember_me_parameter' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - $response = new Response(); - $account = $this->createMock(UserInterface::class); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->once()) - ->method('getUser') - ->willReturn($account) - ; - - $service - ->expects($this->never()) - ->method('onLoginSuccess') - ->willReturn(null) - ; - - $this->assertFalse($request->request->has('foo')); - - $service->loginSuccess($request, $response, $token); - } - - public function testLoginSuccessWhenRememberMeAlwaysIsTrue() - { - $service = $this->getService(null, ['name' => 'foo', 'always_remember_me' => true, 'path' => null, 'domain' => null]); - $request = new Request(); - $response = new Response(); - $account = $this->createMock(UserInterface::class); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->once()) - ->method('getUser') - ->willReturn($account) - ; - - $service - ->expects($this->once()) - ->method('onLoginSuccess') - ->willReturn(null) - ; - - $service->loginSuccess($request, $response, $token); - } - - /** - * @dataProvider getPositiveRememberMeParameterValues - */ - public function testLoginSuccessWhenRememberMeParameterWithPathIsPositive($value) - { - $service = $this->getService(null, ['name' => 'foo', 'always_remember_me' => false, 'remember_me_parameter' => 'foo[bar]', 'path' => null, 'domain' => null]); - - $request = new Request(); - $request->request->set('foo', ['bar' => $value]); - $response = new Response(); - $account = $this->createMock(UserInterface::class); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->once()) - ->method('getUser') - ->willReturn($account) - ; - - $service - ->expects($this->once()) - ->method('onLoginSuccess') - ->willReturn(true) - ; - - $service->loginSuccess($request, $response, $token); - } - - /** - * @dataProvider getPositiveRememberMeParameterValues - */ - public function testLoginSuccessWhenRememberMeParameterIsPositive($value) - { - $service = $this->getService(null, ['name' => 'foo', 'always_remember_me' => false, 'remember_me_parameter' => 'foo', 'path' => null, 'domain' => null]); - - $request = new Request(); - $request->request->set('foo', $value); - $response = new Response(); - $account = $this->createMock(UserInterface::class); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->once()) - ->method('getUser') - ->willReturn($account) - ; - - $service - ->expects($this->once()) - ->method('onLoginSuccess') - ->willReturn(true) - ; - - $service->loginSuccess($request, $response, $token); - } - - public static function getPositiveRememberMeParameterValues() - { - return [ - ['true'], - ['1'], - ['on'], - ['yes'], - [true], - ]; - } - - public function testEncodeCookieAndDecodeCookieAreInvertible() - { - $cookieParts = ['aa', 'bb', 'cc']; - $service = $this->getService(); - - $encoded = $this->callProtected($service, 'encodeCookie', [$cookieParts]); - $this->assertIsString($encoded); - - $decoded = $this->callProtected($service, 'decodeCookie', [$encoded]); - $this->assertSame($cookieParts, $decoded); - } - - public function testThereShouldBeNoCookieDelimiterInCookieParts() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('cookie delimiter'); - $cookieParts = ['aa', 'b'.AbstractRememberMeServices::COOKIE_DELIMITER.'b', 'cc']; - $service = $this->getService(); - - $this->callProtected($service, 'encodeCookie', [$cookieParts]); - } - - protected function getService($userProvider = null, $options = [], $logger = null) - { - if (null === $userProvider) { - $userProvider = $this->getProvider(); - } - - return $this->getMockBuilder(AbstractRememberMeServices::class) - ->setConstructorArgs([ - [$userProvider], 'foosecret', 'fookey', $options, $logger, - ]) - ->onlyMethods(['processAutoLoginCookie', 'onLoginSuccess']) - ->getMock(); - } - - protected function getProvider() - { - $provider = $this->createMock(UserProviderInterface::class); - $provider - ->expects($this->any()) - ->method('supportsClass') - ->willReturn(true) - ; - - return $provider; - } - - private function callProtected($object, $method, array $args) - { - $reflection = new \ReflectionClass(\get_class($object)); - $reflectionMethod = $reflection->getMethod($method); - $reflectionMethod->setAccessible(true); - - return $reflectionMethod->invokeArgs($object, $args); - } -} diff --git a/Tests/RememberMe/PersistentRememberMeHandlerTest.php b/Tests/RememberMe/PersistentRememberMeHandlerTest.php index 76472b1d..a4bbe4d0 100644 --- a/Tests/RememberMe/PersistentRememberMeHandlerTest.php +++ b/Tests/RememberMe/PersistentRememberMeHandlerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\RememberMe; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; @@ -28,11 +29,11 @@ class PersistentRememberMeHandlerTest extends TestCase { - private $tokenProvider; - private $userProvider; - private $requestStack; - private $request; - private $handler; + private MockObject&TokenProviderInterface $tokenProvider; + private InMemoryUserProvider $userProvider; + private RequestStack $requestStack; + private Request $request; + private PersistentRememberMeHandler $handler; protected function setUp(): void { @@ -42,18 +43,16 @@ protected function setUp(): void $this->requestStack = new RequestStack(); $this->request = Request::create('/login'); $this->requestStack->push($this->request); - $this->handler = new PersistentRememberMeHandler($this->tokenProvider, 'secret', $this->userProvider, $this->requestStack, []); + $this->handler = new PersistentRememberMeHandler($this->tokenProvider, $this->userProvider, $this->requestStack, []); } public function testCreateRememberMeCookie() { $this->tokenProvider->expects($this->once()) ->method('createNewToken') - ->with($this->callback(function ($persistentToken) { - return $persistentToken instanceof PersistentToken - && 'wouter' === $persistentToken->getUserIdentifier() - && InMemoryUser::class === $persistentToken->getClass(); - })); + ->with($this->callback(fn ($persistentToken) => $persistentToken instanceof PersistentToken + && 'wouter' === $persistentToken->getUserIdentifier() + && InMemoryUser::class === $persistentToken->getClass())); $this->handler->createRememberMeCookie(new InMemoryUser('wouter', null)); } @@ -75,12 +74,28 @@ public function testClearRememberMeCookie() $this->assertNull($cookie->getValue()); } + public function testClearRememberMeCookieMalformedCookie() + { + $this->tokenProvider->expects($this->exactly(0)) + ->method('deleteTokenBySeries'); + + $this->request->cookies->set('REMEMBERME', 'malformed'); + + $this->handler->clearRememberMeCookie(); + + $this->assertTrue($this->request->attributes->has(ResponseListener::COOKIE_ATTR_NAME)); + + /** @var Cookie $cookie */ + $cookie = $this->request->attributes->get(ResponseListener::COOKIE_ATTR_NAME); + $this->assertNull($cookie->getValue()); + } + public function testConsumeRememberMeCookieValid() { $this->tokenProvider->expects($this->any()) ->method('loadTokenBySeries') ->with('series1') - ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('-10 min'))) + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', $lastUsed = new \DateTimeImmutable('-10 min'))) ; $this->tokenProvider->expects($this->once())->method('updateToken')->with('series1'); @@ -98,17 +113,47 @@ public function testConsumeRememberMeCookieValid() $this->assertSame($rememberParts[0], $cookieParts[0]); // class $this->assertSame($rememberParts[1], $cookieParts[1]); // identifier - $this->assertSame($rememberParts[2], $cookieParts[2]); // expire + $this->assertEqualsWithDelta($lastUsed->getTimestamp() + 31536000, (int) $cookieParts[2], 2); // expire $this->assertNotSame($rememberParts[3], $cookieParts[3]); // value $this->assertSame(explode(':', $rememberParts[3])[0], explode(':', $cookieParts[3])[0]); // series } + public function testConsumeRememberMeCookieInvalidOwner() + { + $this->tokenProvider->expects($this->any()) + ->method('loadTokenBySeries') + ->with('series1') + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('-10 min'))) + ; + + $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'jeremy', 360, 'series1:tokenvalue'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie\'s hash is invalid.'); + $this->handler->consumeRememberMeCookie($rememberMeDetails); + } + + public function testConsumeRememberMeCookieInvalidValue() + { + $this->tokenProvider->expects($this->any()) + ->method('loadTokenBySeries') + ->with('series1') + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('-10 min'))) + ; + + $rememberMeDetails = new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'series1:tokenvalue:somethingelse'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('This token was already used. The account is possibly compromised.'); + $this->handler->consumeRememberMeCookie($rememberMeDetails); + } + public function testConsumeRememberMeCookieValidByValidatorWithoutUpdate() { $verifier = $this->createMock(TokenVerifierInterface::class); - $handler = new PersistentRememberMeHandler($this->tokenProvider, 'secret', $this->userProvider, $this->requestStack, [], null, $verifier); + $handler = new PersistentRememberMeHandler($this->tokenProvider, $this->userProvider, $this->requestStack, [], null, $verifier); - $persistentToken = new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('30 seconds')); + $persistentToken = new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTimeImmutable('30 seconds')); $this->tokenProvider->expects($this->any()) ->method('loadTokenBySeries') @@ -130,30 +175,30 @@ public function testConsumeRememberMeCookieValidByValidatorWithoutUpdate() public function testConsumeRememberMeCookieInvalidToken() { - $this->expectException(CookieTheftException::class); - $this->tokenProvider->expects($this->any()) ->method('loadTokenBySeries') ->with('series1') - ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue1', new \DateTime('-10 min'))); + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue1', new \DateTimeImmutable('-10 min'))); $this->tokenProvider->expects($this->never())->method('updateToken')->with('series1'); + $this->expectException(CookieTheftException::class); + $this->handler->consumeRememberMeCookie(new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'series1:tokenvalue')); } public function testConsumeRememberMeCookieExpired() { - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage('The cookie has expired.'); - $this->tokenProvider->expects($this->any()) ->method('loadTokenBySeries') ->with('series1') - ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('@'.(time() - (31536000 + 1))))); + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTimeImmutable('@'.(time() - (31536000 + 1))))); $this->tokenProvider->expects($this->never())->method('updateToken')->with('series1'); + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie has expired.'); + $this->handler->consumeRememberMeCookie(new RememberMeDetails(InMemoryUser::class, 'wouter', 360, 'series1:tokenvalue')); } @@ -162,7 +207,7 @@ public function testBase64EncodedTokens() $this->tokenProvider->expects($this->any()) ->method('loadTokenBySeries') ->with('series1') - ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTime('-10 min'))) + ->willReturn(new PersistentToken(InMemoryUser::class, 'wouter', 'series1', 'tokenvalue', new \DateTimeImmutable('-10 min'))) ; $this->tokenProvider->expects($this->once())->method('updateToken')->with('series1'); diff --git a/Tests/RememberMe/PersistentTokenBasedRememberMeServicesTest.php b/Tests/RememberMe/PersistentTokenBasedRememberMeServicesTest.php deleted file mode 100644 index 75b5b0cb..00000000 --- a/Tests/RememberMe/PersistentTokenBasedRememberMeServicesTest.php +++ /dev/null @@ -1,331 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\RememberMe; - -use PHPUnit\Framework\SkippedTestSuiteError; -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; -use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface; -use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\CookieTheftException; -use Symfony\Component\Security\Core\Exception\TokenNotFoundException; -use Symfony\Component\Security\Core\User\InMemoryUser; -use Symfony\Component\Security\Core\User\InMemoryUserProvider; -use Symfony\Component\Security\Http\RememberMe\PersistentTokenBasedRememberMeServices; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; - -/** - * @group legacy - */ -class PersistentTokenBasedRememberMeServicesTest extends TestCase -{ - public static function setUpBeforeClass(): void - { - try { - random_bytes(1); - } catch (\Exception $e) { - throw new SkippedTestSuiteError($e->getMessage()); - } - } - - public function testAutoLoginReturnsNullWhenNoCookie() - { - $service = $this->getService(null, ['name' => 'foo']); - - $this->assertNull($service->autoLogin(new Request())); - } - - public function testAutoLoginThrowsExceptionOnInvalidCookie() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => false, 'remember_me_parameter' => 'foo']); - $request = new Request(); - $request->request->set('foo', 'true'); - $request->cookies->set('foo', 'foo'); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testAutoLoginThrowsExceptionOnNonExistentToken() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => false, 'remember_me_parameter' => 'foo']); - $request = new Request(); - $request->request->set('foo', 'true'); - $request->cookies->set('foo', $this->encodeCookie([ - $series = 'fooseries', - $tokenValue = 'foovalue', - ])); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->once()) - ->method('loadTokenBySeries') - ->willThrowException(new TokenNotFoundException('Token not found.')) - ; - $service->setTokenProvider($tokenProvider); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testAutoLoginReturnsNullOnNonExistentUser() - { - $userProvider = $this->getProvider(); - $service = $this->getService($userProvider, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => true, 'lifetime' => 3600, 'secure' => false, 'httponly' => false]); - $request = new Request(); - $request->cookies->set('foo', $this->encodeCookie(['fooseries', 'foovalue'])); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->once()) - ->method('loadTokenBySeries') - ->willReturn(new PersistentToken('fooclass', 'fooname', 'fooseries', $this->generateHash('foovalue'), new \DateTime())) - ; - $service->setTokenProvider($tokenProvider); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)); - } - - public function testAutoLoginThrowsExceptionOnStolenCookieAndRemovesItFromThePersistentBackend() - { - $userProvider = $this->getProvider(); - $service = $this->getService($userProvider, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => true]); - $request = new Request(); - $request->cookies->set('foo', $this->encodeCookie(['fooseries', 'foovalue'])); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $service->setTokenProvider($tokenProvider); - - $tokenProvider - ->expects($this->once()) - ->method('loadTokenBySeries') - ->willReturn(new PersistentToken('fooclass', 'foouser', 'fooseries', 'anotherFooValue', new \DateTime())) - ; - - $tokenProvider - ->expects($this->once()) - ->method('deleteTokenBySeries') - ->with($this->equalTo('fooseries')) - ->willReturn(null) - ; - - try { - $service->autoLogin($request); - $this->fail('Expected CookieTheftException was not thrown.'); - } catch (CookieTheftException $e) { - } - - $this->assertTrue($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)); - } - - public function testAutoLoginDoesNotAcceptAnExpiredCookie() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => true, 'lifetime' => 3600]); - $request = new Request(); - $request->cookies->set('foo', $this->encodeCookie(['fooseries', 'foovalue'])); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->once()) - ->method('loadTokenBySeries') - ->with($this->equalTo('fooseries')) - ->willReturn(new PersistentToken('fooclass', 'username', 'fooseries', $this->generateHash('foovalue'), new \DateTime('yesterday'))) - ; - $service->setTokenProvider($tokenProvider); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)); - } - - /** - * @testWith [true] - * [false] - */ - public function testAutoLogin(bool $hashTokenValue) - { - $user = new InMemoryUser('foouser', null, ['ROLE_FOO']); - - $userProvider = $this->getProvider(); - $userProvider->createUser($user); - - $service = $this->getService($userProvider, ['name' => 'foo', 'path' => null, 'domain' => null, 'secure' => false, 'httponly' => false, 'always_remember_me' => true, 'lifetime' => 3600]); - $request = new Request(); - $request->cookies->set('foo', $this->encodeCookie(['fooseries', 'foovalue'])); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenValue = $hashTokenValue ? $this->generateHash('foovalue') : 'foovalue'; - $tokenProvider - ->expects($this->once()) - ->method('loadTokenBySeries') - ->with($this->equalTo('fooseries')) - ->willReturn(new PersistentToken(InMemoryUser::class, 'foouser', 'fooseries', $tokenValue, new \DateTime())) - ; - $service->setTokenProvider($tokenProvider); - - $returnedToken = $service->autoLogin($request); - - $this->assertInstanceOf(RememberMeToken::class, $returnedToken); - $this->assertTrue($user->isEqualTo($returnedToken->getUser())); - $this->assertEquals('foosecret', $returnedToken->getSecret()); - $this->assertTrue($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)); - } - - public function testLogout() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => '/foo', 'domain' => 'foodomain.foo', 'secure' => true, 'httponly' => false]); - $request = new Request(); - $request->cookies->set('foo', $this->encodeCookie(['fooseries', 'foovalue'])); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->once()) - ->method('deleteTokenBySeries') - ->with($this->equalTo('fooseries')) - ->willReturn(null) - ; - $service->setTokenProvider($tokenProvider); - - $service->logout($request, $response, $token); - - $cookie = $request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME); - $this->assertTrue($cookie->isCleared()); - $this->assertEquals('/foo', $cookie->getPath()); - $this->assertEquals('foodomain.foo', $cookie->getDomain()); - $this->assertTrue($cookie->isSecure()); - $this->assertFalse($cookie->isHttpOnly()); - } - - public function testLogoutSimplyIgnoresNonSetRequestCookie() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->never()) - ->method('deleteTokenBySeries') - ; - $service->setTokenProvider($tokenProvider); - - $service->logout($request, $response, $token); - - $cookie = $request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME); - $this->assertTrue($cookie->isCleared()); - $this->assertEquals('/', $cookie->getPath()); - $this->assertNull($cookie->getDomain()); - } - - public function testLogoutSimplyIgnoresInvalidCookie() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - $request->cookies->set('foo', 'somefoovalue'); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->never()) - ->method('deleteTokenBySeries') - ; - $service->setTokenProvider($tokenProvider); - - $service->logout($request, $response, $token); - - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testLoginFail() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null]); - $request = new Request(); - - $this->assertFalse($request->attributes->has(RememberMeServicesInterface::COOKIE_ATTR_NAME)); - $service->loginFail($request); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testLoginSuccessSetsCookieWhenLoggedInWithNonRememberMeTokenInterfaceImplementation() - { - $service = $this->getService(null, ['name' => 'foo', 'domain' => 'myfoodomain.foo', 'path' => '/foo/path', 'secure' => true, 'httponly' => true, 'samesite' => Cookie::SAMESITE_STRICT, 'lifetime' => 3600, 'always_remember_me' => true]); - $request = new Request(); - $response = new Response(); - - $account = new InMemoryUser('foo', null); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->any()) - ->method('getUser') - ->willReturn($account) - ; - - $tokenProvider = $this->createMock(TokenProviderInterface::class); - $tokenProvider - ->expects($this->once()) - ->method('createNewToken') - ; - $service->setTokenProvider($tokenProvider); - - $cookies = $response->headers->getCookies(); - $this->assertCount(0, $cookies); - - $service->loginSuccess($request, $response, $token); - - $cookies = $response->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY); - $cookie = $cookies['myfoodomain.foo']['/foo/path']['foo']; - $this->assertFalse($cookie->isCleared()); - $this->assertTrue($cookie->isSecure()); - $this->assertTrue($cookie->isHttpOnly()); - $this->assertTrue($cookie->getExpiresTime() > time() + 3590 && $cookie->getExpiresTime() < time() + 3610); - $this->assertEquals('myfoodomain.foo', $cookie->getDomain()); - $this->assertEquals('/foo/path', $cookie->getPath()); - $this->assertSame(Cookie::SAMESITE_STRICT, $cookie->getSameSite()); - } - - protected function encodeCookie(array $parts) - { - $service = $this->getService(); - $r = new \ReflectionMethod($service, 'encodeCookie'); - $r->setAccessible(true); - - return $r->invoke($service, $parts); - } - - protected function getService($userProvider = null, $options = [], $logger = null) - { - if (null === $userProvider) { - $userProvider = $this->getProvider(); - } - - return new PersistentTokenBasedRememberMeServices([$userProvider], 'foosecret', 'fookey', $options, $logger); - } - - protected function getProvider() - { - return new InMemoryUserProvider(); - } - - protected function generateHash(string $tokenValue): string - { - return 'sha256_'.hash_hmac('sha256', $tokenValue, $this->getService()->getSecret()); - } -} diff --git a/Tests/RememberMe/SignatureRememberMeHandlerTest.php b/Tests/RememberMe/SignatureRememberMeHandlerTest.php index 82050094..5a61d3aa 100644 --- a/Tests/RememberMe/SignatureRememberMeHandlerTest.php +++ b/Tests/RememberMe/SignatureRememberMeHandlerTest.php @@ -26,11 +26,11 @@ class SignatureRememberMeHandlerTest extends TestCase { - private $signatureHasher; - private $userProvider; - private $request; - private $requestStack; - private $handler; + private SignatureHasher $signatureHasher; + private InMemoryUserProvider $userProvider; + private Request $request; + private RequestStack $requestStack; + private SignatureRememberMeHandler $handler; protected function setUp(): void { diff --git a/Tests/RememberMe/TokenBasedRememberMeServicesTest.php b/Tests/RememberMe/TokenBasedRememberMeServicesTest.php deleted file mode 100644 index 792afce1..00000000 --- a/Tests/RememberMe/TokenBasedRememberMeServicesTest.php +++ /dev/null @@ -1,232 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Security\Http\Tests\RememberMe; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\User\InMemoryUser; -use Symfony\Component\Security\Core\User\InMemoryUserProvider; -use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; -use Symfony\Component\Security\Http\RememberMe\TokenBasedRememberMeServices; - -/** - * @group legacy - */ -class TokenBasedRememberMeServicesTest extends TestCase -{ - public function testAutoLoginReturnsNullWhenNoCookie() - { - $service = $this->getService(null, ['name' => 'foo']); - - $this->assertNull($service->autoLogin(new Request())); - } - - public function testAutoLoginThrowsExceptionOnInvalidCookie() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => false, 'remember_me_parameter' => 'foo']); - $request = new Request(); - $request->request->set('foo', 'true'); - $request->cookies->set('foo', 'foo'); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testAutoLoginThrowsExceptionOnNonExistentUser() - { - $userProvider = $this->getProvider(); - $service = $this->getService($userProvider, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => true, 'lifetime' => 3600]); - $request = new Request(); - $request->cookies->set('foo', $this->getCookie('fooclass', 'foouser', time() + 3600, 'foopass')); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testAutoLoginDoesNotAcceptCookieWithInvalidHash() - { - $userProvider = $this->getProvider(); - $service = $this->getService($userProvider, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => true, 'lifetime' => 3600]); - $request = new Request(); - $request->cookies->set('foo', base64_encode('class:'.base64_encode('foouser').':123456789:fooHash')); - - $user = new InMemoryUser('foouser', 'foopass'); - $userProvider->createUser($user); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - public function testAutoLoginDoesNotAcceptAnExpiredCookie() - { - $userProvider = $this->getProvider(); - $service = $this->getService($userProvider, ['name' => 'foo', 'path' => null, 'domain' => null, 'always_remember_me' => true, 'lifetime' => 3600]); - $request = new Request(); - $request->cookies->set('foo', $this->getCookie('fooclass', 'foouser', time() - 1, 'foopass')); - - $user = new InMemoryUser('foouser', 'foopass'); - $userProvider->createUser($user); - - $this->assertNull($service->autoLogin($request)); - $this->assertTrue($request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME)->isCleared()); - } - - /** - * @dataProvider provideUsernamesForAutoLogin - * - * @param string $username - */ - public function testAutoLogin($username) - { - $userProvider = $this->getProvider(); - $user = new InMemoryUser($username, 'foopass', ['ROLE_FOO']); - $userProvider->createUser($user); - - $service = $this->getService($userProvider, ['name' => 'foo', 'always_remember_me' => true, 'lifetime' => 3600]); - $request = new Request(); - $request->cookies->set('foo', $this->getCookie(InMemoryUser::class, $username, time() + 3600, 'foopass')); - - $returnedToken = $service->autoLogin($request); - - $this->assertInstanceOf(RememberMeToken::class, $returnedToken); - $this->assertTrue($user->isEqualTo($returnedToken->getUser())); - $this->assertEquals('foosecret', $returnedToken->getSecret()); - } - - public static function provideUsernamesForAutoLogin() - { - return [ - ['foouser', 'Simple username'], - ['foo'.TokenBasedRememberMeServices::COOKIE_DELIMITER.'user', 'Username might contain the delimiter'], - ]; - } - - public function testLogout() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => null, 'domain' => null, 'secure' => true, 'httponly' => false]); - $request = new Request(); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - - $service->logout($request, $response, $token); - - $cookie = $request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME); - $this->assertTrue($cookie->isCleared()); - $this->assertEquals('/', $cookie->getPath()); - $this->assertNull($cookie->getDomain()); - $this->assertTrue($cookie->isSecure()); - $this->assertFalse($cookie->isHttpOnly()); - } - - public function testLoginFail() - { - $service = $this->getService(null, ['name' => 'foo', 'path' => '/foo', 'domain' => 'foodomain.foo']); - $request = new Request(); - - $service->loginFail($request); - - $cookie = $request->attributes->get(RememberMeServicesInterface::COOKIE_ATTR_NAME); - $this->assertTrue($cookie->isCleared()); - $this->assertEquals('/foo', $cookie->getPath()); - $this->assertEquals('foodomain.foo', $cookie->getDomain()); - } - - public function testLoginSuccessIgnoresTokensWhichDoNotContainAnUserInterfaceImplementation() - { - $service = $this->getService(null, ['name' => 'foo', 'always_remember_me' => true, 'path' => null, 'domain' => null]); - $request = new Request(); - $response = new Response(); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->once()) - ->method('getUser') - ->willReturn('foo') - ; - - $cookies = $response->headers->getCookies(); - $this->assertCount(0, $cookies); - - $service->loginSuccess($request, $response, $token); - - $cookies = $response->headers->getCookies(); - $this->assertCount(0, $cookies); - } - - public function testLoginSuccess() - { - $service = $this->getService(null, ['name' => 'foo', 'domain' => 'myfoodomain.foo', 'path' => '/foo/path', 'secure' => true, 'httponly' => true, 'samesite' => Cookie::SAMESITE_STRICT, 'lifetime' => 3600, 'always_remember_me' => true]); - $request = new Request(); - $response = new Response(); - - $user = new InMemoryUser('foouser', 'foopass'); - $token = $this->createMock(TokenInterface::class); - $token - ->expects($this->atLeastOnce()) - ->method('getUser') - ->willReturn($user) - ; - - $cookies = $response->headers->getCookies(); - $this->assertCount(0, $cookies); - - $service->loginSuccess($request, $response, $token); - - $cookies = $response->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY); - $cookie = $cookies['myfoodomain.foo']['/foo/path']['foo']; - $this->assertFalse($cookie->isCleared()); - $this->assertTrue($cookie->isSecure()); - $this->assertTrue($cookie->isHttpOnly()); - $this->assertTrue($cookie->getExpiresTime() > time() + 3590 && $cookie->getExpiresTime() < time() + 3610); - $this->assertEquals('myfoodomain.foo', $cookie->getDomain()); - $this->assertEquals('/foo/path', $cookie->getPath()); - $this->assertSame(Cookie::SAMESITE_STRICT, $cookie->getSameSite()); - } - - protected function getCookie($class, $username, $expires, $password) - { - $service = $this->getService(); - $r = new \ReflectionMethod($service, 'generateCookieValue'); - $r->setAccessible(true); - - return $r->invoke($service, $class, $username, $expires, $password); - } - - protected function encodeCookie(array $parts) - { - $service = $this->getService(); - $r = new \ReflectionMethod($service, 'encodeCookie'); - $r->setAccessible(true); - - return $r->invoke($service, $parts); - } - - protected function getService($userProvider = null, $options = [], $logger = null) - { - if (null === $userProvider) { - $userProvider = $this->getProvider(); - } - - $service = new TokenBasedRememberMeServices([$userProvider], 'foosecret', 'fookey', $options, $logger); - - return $service; - } - - protected function getProvider() - { - return new InMemoryUserProvider(); - } -} diff --git a/Tests/Session/SessionAuthenticationStrategyTest.php b/Tests/Session/SessionAuthenticationStrategyTest.php index b52b2f5a..158baf68 100644 --- a/Tests/Session/SessionAuthenticationStrategyTest.php +++ b/Tests/Session/SessionAuthenticationStrategyTest.php @@ -31,12 +31,14 @@ public function testSessionIsNotChanged() public function testUnsupportedStrategy() { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Invalid session authentication strategy "foo"'); $request = $this->getRequest(); $request->expects($this->never())->method('getSession'); $strategy = new SessionAuthenticationStrategy('foo'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid session authentication strategy "foo"'); + $strategy->onAuthentication($request, $this->createMock(TokenInterface::class)); } diff --git a/Util/TargetPathTrait.php b/Util/TargetPathTrait.php index 67dcc999..c9ec541f 100644 --- a/Util/TargetPathTrait.php +++ b/Util/TargetPathTrait.php @@ -23,7 +23,7 @@ trait TargetPathTrait * * Usually, you do not need to set this directly. */ - private function saveTargetPath(SessionInterface $session, string $firewallName, string $uri) + private function saveTargetPath(SessionInterface $session, string $firewallName, string $uri): void { $session->set('_security.'.$firewallName.'.target_path', $uri); } @@ -39,7 +39,7 @@ private function getTargetPath(SessionInterface $session, string $firewallName): /** * Removes the target path from the session. */ - private function removeTargetPath(SessionInterface $session, string $firewallName) + private function removeTargetPath(SessionInterface $session, string $firewallName): void { $session->remove('_security.'.$firewallName.'.target_path'); } diff --git a/composer.json b/composer.json index deb09da8..77f6af87 100644 --- a/composer.json +++ b/composer.json @@ -16,32 +16,34 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/http-foundation": "^5.3|^6.0", - "symfony/http-kernel": "^5.3|^6.0", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/property-access": "^4.4|^5.0|^6.0", - "symfony/security-core": "^5.4.19|~6.0.19|~6.1.11|^6.2.5", - "symfony/service-contracts": "^1.10|^2|^3" + "symfony/property-access": "^6.4|^7.0", + "symfony/security-core": "^7.3", + "symfony/service-contracts": "^2.5|^3" }, "require-dev": { - "symfony/cache": "^4.4|^5.0|^6.0", - "symfony/rate-limiter": "^5.2|^6.0", - "symfony/routing": "^4.4|^5.0|^6.0", - "symfony/security-csrf": "^4.4|^5.0|^6.0", - "symfony/translation": "^4.4|^5.0|^6.0", - "psr/log": "^1|^2|^3" + "symfony/cache": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/security-csrf": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "psr/log": "^1|^2|^3", + "web-token/jwt-library": "^3.3.2|^4.0" }, "conflict": { - "symfony/event-dispatcher": "<4.3", - "symfony/security-bundle": "<5.3", - "symfony/security-csrf": "<4.4" - }, - "suggest": { - "symfony/security-csrf": "For using tokens to protect authentication/logout attempts", - "symfony/routing": "For using the HttpUtils class to create sub-requests, redirect the user, and match URLs" + "symfony/clock": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<6.4", + "symfony/security-csrf": "<6.4" }, "autoload": { "psr-4": { "Symfony\\Component\\Security\\Http\\": "" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 263aea59..96733956 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - + + ./ - - ./Tests - ./vendor - - - + + + ./Tests + ./vendor + +