1
0
Fork 0
mirror of https://github.com/wallabag/wallabag.git synced 2025-08-26 18:21:02 +00:00

Integrate bdunogier/guzzle-site-authenticator in core

This commit is contained in:
Yassine Guedidi 2024-02-02 21:56:25 +01:00
parent 8abea942b3
commit 03111e510c
24 changed files with 4555 additions and 65 deletions

View file

@ -0,0 +1,24 @@
<?php
namespace Wallabag\CoreBundle\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Wallabag\CoreBundle\Guzzle\FixupMondeDiplomatiqueUriSubscriber;
use Wallabag\CoreBundle\Helper\HttpClientFactory;
class RegisterWallabagGuzzleSubscribersPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$definition = $container->getDefinition(HttpClientFactory::class);
// manually add subscribers for some websites
$definition->addMethodCall(
'addSubscriber', [
new Reference(FixupMondeDiplomatiqueUriSubscriber::class),
]
);
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace Wallabag\CoreBundle\ExpressionLanguage;
use GuzzleHttp\ClientInterface;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface;
class AuthenticatorProvider implements ExpressionFunctionProviderInterface
{
/**
* @var ClientInterface
*/
private $guzzle;
public function __construct(ClientInterface $guzzle)
{
$this->guzzle = $guzzle;
}
public function getFunctions(): array
{
$result = [
$this->getRequestHtmlFunction(),
$this->getXpathFunction(),
$this->getPregMatchFunction(),
];
return $result;
}
private function getRequestHtmlFunction()
{
return new ExpressionFunction(
'request_html',
function () {
throw new \Exception('Not supported');
},
function (array $arguments, $uri, array $options = []) {
return $this->guzzle->get($uri, $options)->getBody();
}
);
}
private function getPregMatchFunction()
{
return new ExpressionFunction(
'preg_match',
function () {
throw new \Exception('Not supported');
},
function (array $arguments, $pattern, $html) {
preg_match($pattern, $html, $matches);
if (2 !== \count($matches)) {
return '';
}
return $matches[1];
}
);
}
private function getXpathFunction()
{
return new ExpressionFunction(
'xpath',
function () {
throw new \Exception('Not supported');
},
function (array $arguments, $xpathQuery, $html) {
$useInternalErrors = libxml_use_internal_errors(true);
$doc = new \DOMDocument();
$doc->loadHTML((string) $html, \LIBXML_NOCDATA | \LIBXML_NOWARNING | \LIBXML_NOERROR);
$xpath = new \DOMXPath($doc);
$domNodeList = $xpath->query($xpathQuery);
if (0 === $domNodeList->length) {
return '';
}
$domNode = $domNodeList->item(0);
libxml_use_internal_errors($useInternalErrors);
if (null === $domNode || null === $domNode->attributes) {
return '';
}
return $domNode->attributes->getNamedItem('value')->nodeValue;
}
);
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace Wallabag\CoreBundle\Guzzle;
use GuzzleHttp\Event\BeforeEvent;
use GuzzleHttp\Event\CompleteEvent;
use GuzzleHttp\Event\SubscriberInterface;
use GuzzleHttp\Message\RequestInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Wallabag\CoreBundle\SiteConfig\Authenticator\Factory;
use Wallabag\CoreBundle\SiteConfig\SiteConfig;
use Wallabag\CoreBundle\SiteConfig\SiteConfigBuilder;
class AuthenticatorSubscriber implements SubscriberInterface, LoggerAwareInterface
{
// avoid loop when login failed which can just be a bad login/password
// after 2 attempts, we skip the login
public const MAX_RETRIES = 2;
private static $retries = 0;
/** @var SiteConfigBuilder */
private $configBuilder;
/** @var Factory */
private $authenticatorFactory;
/** @var LoggerInterface */
private $logger;
/**
* AuthenticatorSubscriber constructor.
*/
public function __construct(SiteConfigBuilder $configBuilder, Factory $authenticatorFactory)
{
$this->configBuilder = $configBuilder;
$this->authenticatorFactory = $authenticatorFactory;
$this->logger = new NullLogger();
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function getEvents(): array
{
return [
'before' => ['loginIfRequired'],
'complete' => ['loginIfRequested'],
];
}
public function loginIfRequired(BeforeEvent $event)
{
$config = $this->buildSiteConfig($event->getRequest());
if (false === $config || !$config->requiresLogin()) {
$this->logger->debug('loginIfRequired> will not require login');
return;
}
$client = $event->getClient();
$authenticator = $this->authenticatorFactory->buildFromSiteConfig($config);
if (!$authenticator->isLoggedIn($client)) {
$this->logger->debug('loginIfRequired> user is not logged in, attach authenticator');
$emitter = $client->getEmitter();
$emitter->detach($this);
$authenticator->login($client);
$emitter->attach($this);
}
}
public function loginIfRequested(CompleteEvent $event)
{
$config = $this->buildSiteConfig($event->getRequest());
if (false === $config || !$config->requiresLogin()) {
$this->logger->debug('loginIfRequested> will not require login');
return;
}
$body = $event->getResponse()->getBody();
if (
null === $body
|| '' === $body->getContents()
) {
$this->logger->debug('loginIfRequested> empty body, ignoring');
return;
}
$authenticator = $this->authenticatorFactory->buildFromSiteConfig($config);
$isLoginRequired = $authenticator->isLoginRequired($body);
$this->logger->debug('loginIfRequested> retry #' . self::$retries . ' with login ' . ($isLoginRequired ? '' : 'not ') . 'required');
if ($isLoginRequired && self::$retries < self::MAX_RETRIES) {
$client = $event->getClient();
$emitter = $client->getEmitter();
$emitter->detach($this);
$authenticator->login($client);
$emitter->attach($this);
$event->retry();
++self::$retries;
}
}
/**
* @return SiteConfig|false
*/
private function buildSiteConfig(RequestInterface $request)
{
return $this->configBuilder->buildForHost($request->getHost());
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Wallabag\CoreBundle\Guzzle;
use GuzzleHttp\Event\CompleteEvent;
use GuzzleHttp\Event\SubscriberInterface;
/**
* Fixes url encoding of a parameter guzzle fails with.
*/
class FixupMondeDiplomatiqueUriSubscriber implements SubscriberInterface
{
public function getEvents(): array
{
return ['complete' => [['fixUri', 500]]];
}
public function fixUri(CompleteEvent $event)
{
$response = $event->getResponse();
if (!$response->hasHeader('Location')) {
return;
}
$uri = $response->getHeader('Location');
if (false === ($badParameter = strstr($uri, 'retour=http://'))) {
return;
}
$response->setHeader('Location', str_replace($badParameter, urlencode($badParameter), $uri));
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Wallabag\CoreBundle\SiteConfig;
class ArraySiteConfigBuilder implements SiteConfigBuilder
{
/**
* Map of hostname => SiteConfig.
*/
private $configs = [];
public function __construct(array $hostConfigMap = [])
{
foreach ($hostConfigMap as $host => $hostConfig) {
$hostConfig['host'] = $host;
$this->configs[$host] = new SiteConfig($hostConfig);
}
}
public function buildForHost($host)
{
$host = strtolower($host);
if ('www.' === substr($host, 0, 4)) {
$host = substr($host, 4);
}
if (isset($this->configs[$host])) {
return $this->configs[$host];
}
return false;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Wallabag\CoreBundle\SiteConfig\Authenticator;
use GuzzleHttp\ClientInterface;
interface Authenticator
{
/**
* Logs the configured user on the given Guzzle client.
*
* @return self
*/
public function login(ClientInterface $guzzle);
/**
* Checks if we are logged into the site, but without calling the server (e.g. do we have a Cookie).
*
* @return bool
*/
public function isLoggedIn(ClientInterface $guzzle);
/**
* Checks from the HTML of a page if authentication is requested by a grabbed page.
*
* @param string $html
*
* @return bool
*/
public function isLoginRequired($html);
}

View file

@ -0,0 +1,21 @@
<?php
namespace Wallabag\CoreBundle\SiteConfig\Authenticator;
use Wallabag\CoreBundle\SiteConfig\SiteConfig;
/**
* Builds an Authenticator based on a SiteConfig.
*/
class Factory
{
/**
* @return Authenticator
*
* @throw \OutOfRangeException if there are no credentials for this host
*/
public function buildFromSiteConfig(SiteConfig $siteConfig)
{
return new LoginFormAuthenticator($siteConfig);
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace Wallabag\CoreBundle\SiteConfig\Authenticator;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Cookie\CookieJar;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Wallabag\CoreBundle\ExpressionLanguage\AuthenticatorProvider;
use Wallabag\CoreBundle\SiteConfig\SiteConfig;
class LoginFormAuthenticator implements Authenticator
{
/** @var \GuzzleHttp\Client */
protected $guzzle;
/** @var SiteConfig */
private $siteConfig;
public function __construct(SiteConfig $siteConfig)
{
// @todo OptionResolver
$this->siteConfig = $siteConfig;
}
public function login(ClientInterface $guzzle)
{
$postFields = [
$this->siteConfig->getUsernameField() => $this->siteConfig->getUsername(),
$this->siteConfig->getPasswordField() => $this->siteConfig->getPassword(),
] + $this->getExtraFields($guzzle);
$guzzle->post(
$this->siteConfig->getLoginUri(),
['body' => $postFields, 'allow_redirects' => true, 'verify' => false]
);
return $this;
}
public function isLoggedIn(ClientInterface $guzzle)
{
if (($cookieJar = $guzzle->getDefaultOption('cookies')) instanceof CookieJar) {
/** @var \GuzzleHttp\Cookie\SetCookie $cookie */
foreach ($cookieJar as $cookie) {
// check required cookies
if ($cookie->getDomain() === $this->siteConfig->getHost()) {
return true;
}
}
}
return false;
}
public function isLoginRequired($html)
{
$useInternalErrors = libxml_use_internal_errors(true);
// need to check for the login dom element ($options['not_logged_in_xpath']) in the HTML
$doc = new \DOMDocument();
$doc->loadHTML($html);
$xpath = new \DOMXPath($doc);
$loggedIn = $xpath->evaluate((string) $this->siteConfig->getNotLoggedInXpath());
if (false === $loggedIn) {
return false;
}
libxml_use_internal_errors($useInternalErrors);
return $loggedIn->length > 0;
}
/**
* Returns extra fields from the configuration.
* Evaluates any field value that is an expression language string.
*
* @return array
*/
private function getExtraFields(ClientInterface $guzzle)
{
$extraFields = [];
foreach ($this->siteConfig->getExtraFields() as $fieldName => $fieldValue) {
if ('@=' === substr($fieldValue, 0, 2)) {
$expressionLanguage = $this->getExpressionLanguage($guzzle);
$fieldValue = $expressionLanguage->evaluate(
substr($fieldValue, 2),
[
'config' => $this->siteConfig,
]
);
}
$extraFields[$fieldName] = $fieldValue;
}
return $extraFields;
}
/**
* @return ExpressionLanguage
*/
private function getExpressionLanguage(ClientInterface $guzzle)
{
return new ExpressionLanguage(
null,
[new AuthenticatorProvider($guzzle)]
);
}
}

View file

@ -2,8 +2,6 @@
namespace Wallabag\CoreBundle\SiteConfig;
use BD\GuzzleSiteAuthenticator\SiteConfig\SiteConfig;
use BD\GuzzleSiteAuthenticator\SiteConfig\SiteConfigBuilder;
use Graby\SiteConfig\ConfigBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

View file

@ -0,0 +1,263 @@
<?php
namespace Wallabag\CoreBundle\SiteConfig;
/**
* Authentication configuration for a site.
*/
class SiteConfig
{
/**
* The site's host name.
*
* @var string
*/
protected $host;
/**
* If the site requires a loogin or not.
*
* @var bool
*/
protected $requiresLogin;
/**
* XPath query used to check if the user was logged in or not.
*
* @var string
*/
protected $notLoggedInXpath;
/**
* URI login data must be sent to.
*
* @var string
*/
protected $loginUri;
/**
* Name of the username field.
*
* @var string
*/
protected $usernameField;
/**
* Name of the password field.
*
* @var string
*/
protected $passwordField;
/**
* Associative array of extra fields to send with the form.
*
* @var array
*/
protected $extraFields = [];
/**
* Username to use for login.
*
* @var string
*/
protected $username;
/**
* Password to use for login.
*
* @var string
*/
protected $password;
/**
* SiteConfig constructor. Sets the properties by name given a hash.
*
* @throws \InvalidArgumentException if a property doesn't exist
*/
public function __construct(array $properties = [])
{
foreach ($properties as $propertyName => $propertyValue) {
if (!property_exists($this, $propertyName)) {
throw new \InvalidArgumentException('Unknown property: "' . $propertyName . '"');
}
$this->$propertyName = $propertyValue;
}
}
/**
* @return bool
*/
public function requiresLogin()
{
return $this->requiresLogin;
}
/**
* @param bool $requiresLogin
*
* @return SiteConfig
*/
public function setRequiresLogin($requiresLogin)
{
$this->requiresLogin = $requiresLogin;
return $this;
}
/**
* @return string
*/
public function getNotLoggedInXpath()
{
return $this->notLoggedInXpath;
}
/**
* @param string $notLoggedInXpath
*
* @return SiteConfig
*/
public function setNotLoggedInXpath($notLoggedInXpath)
{
$this->notLoggedInXpath = $notLoggedInXpath;
return $this;
}
/**
* @return string
*/
public function getLoginUri()
{
return $this->loginUri;
}
/**
* @param string $loginUri
*
* @return SiteConfig
*/
public function setLoginUri($loginUri)
{
$this->loginUri = $loginUri;
return $this;
}
/**
* @return string
*/
public function getUsernameField()
{
return $this->usernameField;
}
/**
* @param string $usernameField
*
* @return SiteConfig
*/
public function setUsernameField($usernameField)
{
$this->usernameField = $usernameField;
return $this;
}
/**
* @return string
*/
public function getPasswordField()
{
return $this->passwordField;
}
/**
* @param string $passwordField
*
* @return SiteConfig
*/
public function setPasswordField($passwordField)
{
$this->passwordField = $passwordField;
return $this;
}
/**
* @return array
*/
public function getExtraFields()
{
return $this->extraFields;
}
/**
* @param array $extraFields
*
* @return SiteConfig
*/
public function setExtraFields($extraFields)
{
$this->extraFields = $extraFields;
return $this;
}
/**
* @return string
*/
public function getHost()
{
return $this->host;
}
/**
* @param string $host
*
* @return SiteConfig
*/
public function setHost($host)
{
$this->host = $host;
return $this;
}
public function getUsername()
{
return $this->username;
}
/**
* @return SiteConfig
*/
public function setUsername($username)
{
$this->username = $username;
return $this;
}
/**
* @return string
*/
public function getPassword()
{
return $this->password;
}
/**
* @param string $password
*
* @return SiteConfig
*/
public function setPassword($password)
{
$this->password = $password;
return $this;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Wallabag\CoreBundle\SiteConfig;
interface SiteConfigBuilder
{
/**
* Builds the SiteConfig for a host.
*
* @param string $host The "www." prefix is ignored.
*
* @throws \OutOfRangeException If there is no config for $host
*
* @return SiteConfig|false
*/
public function buildForHost($host);
}

View file

@ -4,6 +4,7 @@ namespace Wallabag\CoreBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Wallabag\CoreBundle\DependencyInjection\CompilerPass\RegisterWallabagGuzzleSubscribersPass;
use Wallabag\CoreBundle\Import\ImportCompilerPass;
class WallabagCoreBundle extends Bundle
@ -13,5 +14,6 @@ class WallabagCoreBundle extends Bundle
parent::build($container);
$container->addCompilerPass(new ImportCompilerPass());
$container->addCompilerPass(new RegisterWallabagGuzzleSubscribersPass());
}
}