The start of something beautiful

This commit is contained in:
2024-09-11 22:48:07 -06:00
parent 45acea47f3
commit f5997ee5ec
5614 changed files with 630696 additions and 0 deletions
@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <florent.morselli@spomky-labs.com>
*/
interface AccessTokenExtractorInterface
{
public function extractAccessToken(Request $request): ?string;
}
@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <florent.morselli@spomky-labs.com>
*/
interface AccessTokenHandlerInterface
{
/**
* @throws AuthenticationException
*/
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge;
}
@@ -0,0 +1,85 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <contact@nicolasattard.fr>
*/
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)
);
}
}
@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <florent.morselli@spomky-labs.com>
*/
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;
}
}
@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <florent.morselli@spomky-labs.com>
*
* @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;
}
}
@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <florent.morselli@spomky-labs.com>
*
* @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;
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.';
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.';
}
}
@@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Signature\JWSTokenSupport;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
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;
/**
* The token handler decodes and validates the token, and retrieves the user identifier from it.
*/
final class OidcTokenHandler implements AccessTokenHandlerInterface
{
use OidcTrait;
public function __construct(
private Algorithm|AlgorithmManager $signatureAlgorithm,
private JWK|JWKSet $jwkset,
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 ($jwkset instanceof JWK) {
trigger_deprecation('symfony/security-http', '7.1', 'Second argument must be instance of %s, %s given.', JWKSet::class, JWK::class);
$this->jwkset = new JWKSet([$jwkset]);
}
}
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".');
}
try {
// Decode the token
$jwsVerifier = new JWSVerifier($this->signatureAlgorithm);
$serializerManager = new JWSSerializerManager([new CompactSerializer()]);
$jws = $serializerManager->unserialize($accessToken);
$claims = json_decode($jws->getPayload(), true);
// Verify the signature
if (!$jwsVerifier->verifyWithKeySet($jws, $this->jwkset, 0)) {
throw new InvalidSignatureException();
}
// Verify the headers
$headerCheckerManager = new Checker\HeaderCheckerManager([
new Checker\AlgorithmChecker($this->signatureAlgorithm->list()),
], [
new JWSTokenSupport(),
]);
// if this check fails, an InvalidHeaderException is thrown
$headerCheckerManager->check($jws, 0);
// Verify the claims
$checkers = [
new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false),
new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false),
new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false),
new Checker\AudienceChecker($this->audience),
new Checker\IssuerChecker($this->issuers),
];
$claimCheckerManager = new ClaimCheckerManager($checkers);
// if this check fails, an InvalidClaimException is thrown
$claimCheckerManager->check($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(fn () => $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);
}
}
}
@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}
@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\HttpClient\HttpClientInterface;
/**
* The token handler validates the token on the OIDC server and retrieves the user identifier.
*/
final class OidcUserInfoTokenHandler implements AccessTokenHandlerInterface
{
use OidcTrait;
public function __construct(
private HttpClientInterface $client,
private ?LoggerInterface $logger = null,
private string $claim = 'sub',
) {
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
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', '', [
'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(fn () => $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);
}
}
}
@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <florent.morselli@spomky-labs.com>
*
* @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;
}
}