Skip to content

Commit

Permalink
Google openid login integration
Browse files Browse the repository at this point in the history
  • Loading branch information
vtsykun committed Sep 10, 2023
1 parent 5e739c5 commit c416c5e
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 4 deletions.
13 changes: 13 additions & 0 deletions src/Attribute/AsIntegration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Packeton\Attribute;

#[\Attribute(\Attribute::TARGET_CLASS)]
class AsIntegration
{
public function __construct(public readonly string $nameOrClass)
{
}
}
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ private function defaultIconsData(): array
'logo' => '/packeton/img/logo/bitbucket.png',
'svg_logo' => 'svg/bitbucket.html.twig',
'login_title' => 'Login with Bitbucket',
],
'google' => [
'svg_logo' => 'svg/google.html.twig',
'login_title' => 'Login with Google',
]
];
}
Expand Down
42 changes: 40 additions & 2 deletions src/DependencyInjection/PacketonExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,28 @@

namespace Packeton\DependencyInjection;

use Packeton\Attribute\AsIntegration;
use Packeton\Attribute\AsWorker;
use Packeton\Integrations\Bitbucket\BitbucketOAuth2Factory;
use Packeton\Integrations\Factory\OAuth2Factory;
use Packeton\Integrations\Factory\OAuth2FactoryInterface;
use Packeton\Integrations\Gitea\GiteaOAuth2Factory;
use Packeton\Integrations\Github\GithubAppFactory;
use Packeton\Integrations\Github\GithubOAuth2Factory;
use Packeton\Integrations\Gitlab\GitLabOAuth2Factory;
use Packeton\Integrations\IntegrationInterface;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

class PacketonExtension extends Extension
class PacketonExtension extends Extension implements PrependExtensionInterface
{
/** @var OAuth2FactoryInterface[] */
protected $factories = [];

protected ?ContainerBuilder $mergeContainer = null;

public function __construct()
{
$this->addFactories(new GithubOAuth2Factory());
Expand Down Expand Up @@ -47,7 +53,9 @@ public function getFactories(): array
*/
public function load(array $configs, ContainerBuilder $container): void
{
$this->loadAutoconfigureFactories();
$configuration = new Configuration($this->factories);

$config = $this->processConfiguration($configuration, $configs);

$container->setParameter('packagist_web.rss_max_items', $config['rss_max_items']);
Expand Down Expand Up @@ -84,10 +92,40 @@ public function load(array $configs, ContainerBuilder $container): void
}

/**
* @return string
* {@inheritdoc}
*/
public function getAlias(): string
{
return 'packeton';
}

/**
* {@inheritdoc}
*/
public function prepend(ContainerBuilder $container): void
{
$this->mergeContainer = $container;
}

protected function loadAutoconfigureFactories(): void
{
if (null === $container = $this->mergeContainer) {
return;
}

$definitions = array_keys($container->getDefinitions());
$definitions = array_filter($definitions, static fn ($name) => str_starts_with((string)$name, 'Packeton\\') && class_exists($name) && is_subclass_of($name, IntegrationInterface::class));
foreach ($definitions as $className) {
/** @var AsIntegration $attribute */
if (!$attribute = ((new \ReflectionClass($className))->getAttributes(AsIntegration::class)[0] ?? null)?->newInstance()) {
continue;
}

if (class_exists($alias = $attribute->nameOrClass)) {
$this->addFactories(new $alias);
} else {
$this->addFactories(new OAuth2Factory($alias, $className));
}
}
}
}
21 changes: 19 additions & 2 deletions src/Integrations/Base/BaseIntegrationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@

use Okvpn\Expression\TwigLanguage;
use Packeton\Entity\OAuthIntegration;
use Packeton\Entity\User;
use Packeton\Integrations\Model\AppConfig;
use Packeton\Integrations\Model\OAuth2ExpressionExtension;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

trait BaseIntegrationTrait
{
protected $defaultScopes = [''];
protected ?TwigLanguage $exprLang = null;

/**
Expand Down Expand Up @@ -141,7 +141,7 @@ protected function getAuthorizationUrl(string $baseUrl, array $options, string $
protected function getAuthorizationParameters(array $options, string $route = 'oauth_check'): array
{
if (empty($options['scope'] ?? [])) {
$options['scope'] = $this->defaultScopes;
$options['scope'] = $this->defaultScopes ?? [];
}

$options += [
Expand All @@ -165,4 +165,21 @@ protected function getAuthorizationParameters(array $options, string $route = 'o

return $options;
}

public function createUser(array $data): User
{
$username = $data['user_name'] ?? $data['user_identifier'];
$username = preg_replace('#[^a-z0-9-_]#i', '_', $username);
$email = $data['email'] ?? (str_contains($data['user_identifier'], '@') ? $data['user_identifier'] : $data['user_identifier'] .'@example.com');

$user = new User();
$user->setEnabled(true)
->setRoles($this->getConfig()->roles())
->setEmail($email)
->setUsername($username)
->setGithubId($data['external_id'] ?? null)
->generateApiToken();

return $user;
}
}
17 changes: 17 additions & 0 deletions src/Integrations/Factory/OAuth2Factory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Packeton\Integrations\Factory;

use Symfony\Component\DependencyInjection\Attribute\Exclude;

#[Exclude]
class OAuth2Factory implements OAuth2FactoryInterface
{
use OAuth2FactoryTrait;

public function __construct(private readonly string $key, private readonly string $class)
{
}
}
105 changes: 105 additions & 0 deletions src/Integrations/Google/GoogleOAuth2Login.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace Packeton\Integrations\Google;

use Okvpn\Expression\TwigLanguage;
use Packeton\Attribute\AsIntegration;
use Packeton\Integrations\Base\BaseIntegrationTrait;
use Packeton\Integrations\LoginInterface;
use Packeton\Integrations\Model\OAuth2State;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface as UG;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsIntegration('google')]
class GoogleOAuth2Login implements LoginInterface
{
use BaseIntegrationTrait;

protected $name;
protected $separator = ' ';
protected $defaultScopes = ['openid', 'email'];

public function __construct(
protected array $config,
protected HttpClientInterface $httpClient,
protected RouterInterface $router,
protected OAuth2State $state,
protected TwigLanguage $twigLanguage,
protected LoggerInterface $logger,
) {
$this->name = $config['name'];

if (empty($this->config['default_roles'])) {
$this->config['default_roles'] = ['ROLE_MAINTAINER', 'ROLE_SSO_GOOGLE'];
}
}

/**
* {@inheritdoc}
*/
public function redirectOAuth2Url(Request $request = null, array $options = []): Response
{
return $this->getAuthorizationResponse('https://accounts.google.com/o/oauth2/v2/auth', $options);
}

/**
* {@inheritdoc}
*/
public function getAccessToken(Request $request, array $options = []): array
{
if (!$request->get('code') || !$this->checkState($request->get('state'))) {
throw new BadRequestHttpException('No "code" and "state" parameter was found (usually this is a query parameter)!');
}

$route = $this->state->getStateBag()->get('route');
$redirectUrl = $this->router->generate($route, ['alias' => $this->name], UG::ABSOLUTE_URL);
$query = [
'client_id' => $this->config['client_id'],
'client_secret' => $this->config['client_secret'],
'code' => $request->get('code'),
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUrl,
];

$response = $this->httpClient->request('POST', 'https://oauth2.googleapis.com/token', ['body' => $query]);

return $response->toArray();
}

/**
* {@inheritdoc}
*/
public function fetchUser(array|Request $request, array $options = [], array &$accessToken = null): array
{
$accessToken ??= $request instanceof Request ? $this->getAccessToken($request) : $request;

$response = $this->httpClient->request('GET', 'https://openidconnect.googleapis.com/v1/userinfo', $this->getAuthorizationHeaders($accessToken));

$response = $response->toArray();
if (false === ($response['email_verified'] ?? null)) {
throw new BadRequestHttpException('Google email_verified is false!');
}

$response['user_name'] = explode('@', $response['email'])[0];
$response['user_identifier'] = $response['email'];
$response['external_id'] = isset($response['sub']) ? $this->name . ':' . $response['sub'] : null;

return $response;
}

protected function getAuthorizationHeaders(array $token): array
{
return array_merge_recursive($this->config['http_options'] ?? [], [
'headers' => [
'Authorization' => "Bearer {$token['access_token']}",
]
]);
}
}
1 change: 1 addition & 0 deletions templates/svg/google.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 488 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"/></svg>

0 comments on commit c416c5e

Please sign in to comment.