334 lines
11 KiB
PHP
334 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Engelsystem\Controllers;
|
|
|
|
use Carbon\Carbon;
|
|
use Engelsystem\Config\Config;
|
|
use Engelsystem\Helpers\Authenticator;
|
|
use Engelsystem\Http\Exceptions\HttpNotFound;
|
|
use Engelsystem\Http\Redirector;
|
|
use Engelsystem\Http\Request;
|
|
use Engelsystem\Http\Response;
|
|
use Engelsystem\Http\UrlGenerator;
|
|
use Engelsystem\Models\OAuth;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use League\OAuth2\Client\Provider\AbstractProvider;
|
|
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
|
use League\OAuth2\Client\Provider\GenericProvider;
|
|
use League\OAuth2\Client\Provider\ResourceOwnerInterface as ResourceOwner;
|
|
use League\OAuth2\Client\Token\AccessTokenInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\HttpFoundation\Session\Session as Session;
|
|
|
|
class OAuthController extends BaseController
|
|
{
|
|
use HasUserNotifications;
|
|
|
|
public function __construct(
|
|
protected Authenticator $auth,
|
|
protected AuthController $authController,
|
|
protected Config $config,
|
|
protected LoggerInterface $log,
|
|
protected OAuth $oauth,
|
|
protected Redirector $redirect,
|
|
protected Session $session,
|
|
protected UrlGenerator $url
|
|
) {
|
|
}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$providerName = $request->getAttribute('provider');
|
|
|
|
$provider = $this->getProvider($providerName);
|
|
$config = $this->config->get('oauth')[$providerName];
|
|
|
|
// Handle OAuth error response according to https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1
|
|
if ($request->has('error')) {
|
|
throw new HttpNotFound('oauth.' . $request->get('error'));
|
|
}
|
|
|
|
if (!$request->has('code')) {
|
|
$authorizationUrl = $provider->getAuthorizationUrl(
|
|
[
|
|
// League oauth separates scopes by comma, which is wrong, so we do it
|
|
// here properly by spaces. See https://www.rfc-editor.org/rfc/rfc6749#section-3.3
|
|
'scope' => join(' ', $config['scope'] ?? []),
|
|
]
|
|
);
|
|
$this->session->set('oauth2_state', $provider->getState());
|
|
|
|
return $this->redirect->to($authorizationUrl);
|
|
}
|
|
|
|
if (
|
|
!$this->session->get('oauth2_state')
|
|
|| $request->get('state') !== $this->session->get('oauth2_state')
|
|
) {
|
|
$this->session->remove('oauth2_state');
|
|
|
|
$this->log->warning('Invalid OAuth state');
|
|
|
|
throw new HttpNotFound('oauth.invalid-state');
|
|
}
|
|
|
|
$accessToken = null;
|
|
try {
|
|
$accessToken = $provider->getAccessToken(
|
|
'authorization_code',
|
|
[
|
|
'code' => $request->get('code'),
|
|
]
|
|
);
|
|
} catch (IdentityProviderException $e) {
|
|
$this->handleOAuthError($e, $providerName);
|
|
}
|
|
|
|
$resourceOwner = null;
|
|
try {
|
|
$resourceOwner = $provider->getResourceOwner($accessToken);
|
|
} catch (IdentityProviderException $e) {
|
|
$this->handleOAuthError($e, $providerName);
|
|
}
|
|
$resourceId = $this->getId($providerName, $resourceOwner);
|
|
|
|
/** @var OAuth|null $oauth */
|
|
$oauth = $this->oauth
|
|
->query()
|
|
->where('provider', $providerName)
|
|
->where('identifier', $resourceId)
|
|
->get()
|
|
// Explicit case-sensitive comparison using PHP as some DBMS collations are case-sensitive and some aren't
|
|
->where('identifier', '===', (string) $resourceId)
|
|
->first();
|
|
|
|
$expirationTime = $accessToken->getExpires();
|
|
$expirationTime = $expirationTime ? Carbon::createFromTimestamp($expirationTime) : null;
|
|
if ($oauth) {
|
|
$oauth->access_token = $accessToken->getToken();
|
|
$oauth->refresh_token = $accessToken->getRefreshToken();
|
|
$oauth->expires_at = $expirationTime;
|
|
|
|
$oauth->save();
|
|
}
|
|
|
|
$user = $this->auth->user();
|
|
if ($oauth && $user && $user->id != $oauth->user_id) {
|
|
throw new HttpNotFound('oauth.already-connected');
|
|
}
|
|
|
|
$connectProvider = $this->session->get('oauth2_connect_provider');
|
|
$this->session->remove('oauth2_connect_provider');
|
|
if (!$oauth && $user && $connectProvider && $connectProvider == $providerName) {
|
|
$oauth = new OAuth([
|
|
'provider' => $providerName,
|
|
'identifier' => $resourceId,
|
|
'access_token' => $accessToken->getToken(),
|
|
'refresh_token' => $accessToken->getRefreshToken(),
|
|
'expires_at' => $expirationTime,
|
|
]);
|
|
$oauth->user()
|
|
->associate($user)
|
|
->save();
|
|
|
|
$this->log->info(
|
|
'Connected OAuth user {user} using {provider}',
|
|
['provider' => $providerName, 'user' => $resourceId]
|
|
);
|
|
$this->addNotification('oauth.connected');
|
|
}
|
|
|
|
$resourceData = $resourceOwner->toArray();
|
|
if (!empty($config['nested_info'])) {
|
|
$resourceData = Arr::dot($resourceData);
|
|
}
|
|
|
|
$userdata = new Collection($resourceData);
|
|
if (!$oauth) {
|
|
return $this->redirectRegister(
|
|
$providerName,
|
|
(string) $resourceId,
|
|
$accessToken,
|
|
$config,
|
|
$userdata
|
|
);
|
|
}
|
|
|
|
if (isset($config['mark_arrived']) && $config['mark_arrived']) {
|
|
$this->handleArrive($providerName, $oauth, $resourceOwner);
|
|
}
|
|
|
|
$response = $this->authController->loginUser($oauth->user);
|
|
event('oauth2.login', ['provider' => $providerName, 'data' => $userdata]);
|
|
|
|
return $response;
|
|
}
|
|
|
|
public function connect(Request $request): Response
|
|
{
|
|
$providerName = $request->getAttribute('provider');
|
|
|
|
$this->requireProvider($providerName);
|
|
|
|
$this->session->set('oauth2_connect_provider', $providerName);
|
|
|
|
return $this->index($request);
|
|
}
|
|
|
|
public function disconnect(Request $request): Response
|
|
{
|
|
$providerName = $request->getAttribute('provider');
|
|
|
|
$this->oauth
|
|
->whereUserId($this->auth->user()->id)
|
|
->where('provider', $providerName)
|
|
->delete();
|
|
|
|
$this->log->info('Disconnected OAuth from {provider}', ['provider' => $providerName]);
|
|
$this->addNotification('oauth.disconnected');
|
|
|
|
return $this->redirect->back();
|
|
}
|
|
|
|
protected function getProvider(string $name): AbstractProvider
|
|
{
|
|
$this->requireProvider($name);
|
|
$config = $this->config->get('oauth')[$name];
|
|
|
|
return new GenericProvider(
|
|
[
|
|
'clientId' => $config['client_id'],
|
|
'clientSecret' => $config['client_secret'],
|
|
'redirectUri' => $this->url->to('oauth/' . $name),
|
|
'urlAuthorize' => $config['url_auth'],
|
|
'urlAccessToken' => $config['url_token'],
|
|
'urlResourceOwnerDetails' => $config['url_info'],
|
|
'responseResourceOwnerId' => $config['id'],
|
|
]
|
|
);
|
|
}
|
|
|
|
protected function getId(string $providerName, ResourceOwner $resourceOwner): mixed
|
|
{
|
|
$config = $this->config->get('oauth')[$providerName];
|
|
if (empty($config['nested_info'])) {
|
|
return $resourceOwner->getId();
|
|
}
|
|
|
|
$data = Arr::dot($resourceOwner->toArray());
|
|
return $data[$config['id']];
|
|
}
|
|
|
|
protected function requireProvider(string $provider): void
|
|
{
|
|
if (!$this->isValidProvider($provider)) {
|
|
throw new HttpNotFound('oauth.provider-not-found');
|
|
}
|
|
}
|
|
|
|
protected function isValidProvider(string $name): bool
|
|
{
|
|
$config = $this->config->get('oauth');
|
|
|
|
return isset($config[$name]);
|
|
}
|
|
|
|
protected function handleArrive(
|
|
string $providerName,
|
|
OAuth $auth,
|
|
ResourceOwner $resourceOwner
|
|
): void {
|
|
$user = $auth->user;
|
|
$userState = $user->state;
|
|
|
|
if ($userState->arrived) {
|
|
return;
|
|
}
|
|
|
|
$userState->arrived = true;
|
|
$userState->arrival_date = new Carbon();
|
|
$userState->save();
|
|
|
|
$this->log->info(
|
|
'Set user {name} ({id}) as arrived via {provider} user {user}',
|
|
[
|
|
'provider' => $providerName,
|
|
'user' => $this->getId($providerName, $resourceOwner),
|
|
'name' => $user->name,
|
|
'id' => $user->id,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @throws HttpNotFound
|
|
*/
|
|
protected function handleOAuthError(IdentityProviderException $e, string $providerName): void
|
|
{
|
|
$response = $e->getResponseBody();
|
|
$response = is_array($response) ? json_encode($response) : $response;
|
|
$this->log->error(
|
|
'{provider} identity provider error: {error} {description}',
|
|
[
|
|
'provider' => $providerName,
|
|
'error' => $e->getMessage(),
|
|
'description' => $response,
|
|
]
|
|
);
|
|
|
|
throw new HttpNotFound('oauth.provider-error');
|
|
}
|
|
|
|
protected function redirectRegister(
|
|
string $providerName,
|
|
string $providerUserIdentifier,
|
|
AccessTokenInterface $accessToken,
|
|
array $config,
|
|
Collection $userdata
|
|
): Response {
|
|
$config = array_merge(
|
|
[
|
|
'username' => null,
|
|
'email' => null,
|
|
'first_name' => null,
|
|
'last_name' => null,
|
|
'enable_password' => false,
|
|
'allow_registration' => null,
|
|
'groups' => null,
|
|
],
|
|
$config
|
|
);
|
|
|
|
if (!$this->config->get('registration_enabled') && !$config['allow_registration']) {
|
|
throw new HttpNotFound('oauth.not-found');
|
|
}
|
|
|
|
$this->session->set(
|
|
'form_data',
|
|
[
|
|
'name' => $userdata->get($config['username']),
|
|
'email' => $userdata->get($config['email']),
|
|
'first_name' => $userdata->get($config['first_name']),
|
|
'last_name' => $userdata->get($config['last_name']),
|
|
],
|
|
);
|
|
$this->session->set('oauth2_groups', $userdata->get($config['groups'], []));
|
|
$this->session->set('oauth2_connect_provider', $providerName);
|
|
$this->session->set('oauth2_user_id', $providerUserIdentifier);
|
|
|
|
$expirationTime = $accessToken->getExpires();
|
|
$expirationTime = $expirationTime ? Carbon::createFromTimestamp($expirationTime) : null;
|
|
$this->session->set('oauth2_access_token', $accessToken->getToken());
|
|
$this->session->set('oauth2_refresh_token', $accessToken->getRefreshToken());
|
|
$this->session->set('oauth2_expires_at', $expirationTime);
|
|
$this->session->set('oauth2_enable_password', $config['enable_password']);
|
|
$this->session->set('oauth2_allow_registration', $config['allow_registration']);
|
|
|
|
return $this->redirect->to('/sign-up');
|
|
}
|
|
}
|