Added OAuth2 SSO login group mapping

This commit is contained in:
Igor Scheller 2021-11-23 11:59:53 +01:00 committed by msquare
parent 38dda01330
commit 1ba4b57eac
8 changed files with 237 additions and 27 deletions

View File

@ -66,5 +66,7 @@ return [
// or $function // or $function
// ] // ]
'news.created' => \Engelsystem\Events\Listener\News::class . '@created', 'news.created' => \Engelsystem\Events\Listener\News::class . '@created',
'oauth2.login' => \Engelsystem\Events\Listener\OAuth2::class . '@login',
], ],
]; ];

View File

@ -105,6 +105,14 @@ return [
'hidden' => false, 'hidden' => false,
// Mark user as arrived when using this provider (optional) // Mark user as arrived when using this provider (optional)
'mark_arrived' => false, 'mark_arrived' => false,
// Auto join teams
// Info groups field (optional)
'groups' => 'groups',
// Groups to team (angeltype) mapping (optional)
'teams' => [
'/Lorem' => 4, // 4 being the ID of the angeltype
'/Foo Mod' => ['id' => 5, 'supporter' => true], // 5 being the ID of the angeltype
],
], ],
*/ */
], ],

View File

@ -0,0 +1,124 @@
<?php
namespace Engelsystem\Events\Listener;
use Engelsystem\Config\Config;
use Engelsystem\Database\Database;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Psr\Log\LoggerInterface;
class OAuth2
{
/** @var array */
protected $config;
/** @var LoggerInterface */
protected $log;
/**
* @param Config $config
* @param LoggerInterface $log
*/
public function __construct(Config $config, LoggerInterface $log)
{
$this->config = $config->get('oauth');
$this->log = $log;
}
/**
* @param string $event
* @param string $provider
* @param Collection $data OAuth userdata
*/
public function login(string $event, string $provider, Collection $data)
{
/** @var Database $db */
$db = app(Database::class);
$ssoTeams = $this->getSsoTeams($provider);
$user = auth()->user();
$currentUserAngeltypes = collect($db->select('SELECT * FROM UserAngelTypes WHERE user_id = ?', [$user->id]));
$userGroups = $data->get(($this->config[$provider] ?? [])['groups'] ?? 'groups', []);
foreach ($userGroups as $groupName) {
if (!isset($ssoTeams[$groupName])) {
continue;
}
$team = $ssoTeams[$groupName];
$userAngeltype = $currentUserAngeltypes->where('angeltype_id', $team['id'])->first();
$supporter = $team['supporter'];
$confirmed = $supporter ? $user->id : null;
if (!$userAngeltype) {
$this->log->info(
'SSO {provider}: Added to angeltype {angeltype}, confirmed: {confirmed}, supporter: {supporter}',
[
'provider' => $provider,
'angeltype' => AngelType($team['id'])['name'],
'confirmed' => $confirmed ? 'yes' : 'no',
'supporter' => $supporter ? 'yes' : 'no',
]
);
$db->insert(
'INSERT INTO UserAngelTypes (user_id, angeltype_id, confirm_user_id, supporter) VALUES (?, ?, ?, ?)',
[$user->id, $team['id'], $confirmed, $supporter]
);
continue;
}
if (!$supporter) {
continue;
}
if ($userAngeltype->supporter != $supporter) {
$db->update(
'UPDATE UserAngelTypes SET supporter=? WHERE id = ?',
[$supporter, $userAngeltype->id]
);
$this->log->info(
'SSO {provider}: Set supporter state for angeltype {angeltype}',
[
'provider' => $provider,
'angeltype' => AngelType($userAngeltype->angeltype_id)['name'],
]
);
}
if (!$userAngeltype->confirm_user_id) {
$db->update(
'UPDATE UserAngelTypes SET confirm_user_id=? WHERE id = ?',
[$user->id, $userAngeltype->id]
);
$this->log->info(
'SSO {provider}: Set confirmed state for angeltype {angeltype}',
[
'provider' => $provider,
'angeltype' => AngelType($userAngeltype->angeltype_id)['name'],
]
);
}
}
}
/**
* @param string $provider
* @return array
*/
public function getSsoTeams(string $provider): array
{
$config = $this->config[$provider] ?? [];
$teams = [];
foreach ($config['teams'] ?? [] as $ssoName => $conf) {
$conf = Arr::wrap($conf);
$teamId = $conf['id'] ?? $conf[0];
$isSupporter = $conf['supporter'] ?? false;
$teams[$ssoName] = ['id' => $teamId, 'supporter' => $isSupporter];
}
return $teams;
}
}

View File

@ -60,6 +60,7 @@ $includeFiles = [
__DIR__ . '/../includes/helper/legacy_helper.php', __DIR__ . '/../includes/helper/legacy_helper.php',
__DIR__ . '/../includes/helper/message_helper.php', __DIR__ . '/../includes/helper/message_helper.php',
__DIR__ . '/../includes/helper/email_helper.php', __DIR__ . '/../includes/helper/email_helper.php',
__DIR__ . '/../includes/helper/oauth_helper.php',
__DIR__ . '/../includes/mailer/shifts_mailer.php', __DIR__ . '/../includes/mailer/shifts_mailer.php',
__DIR__ . '/../includes/mailer/users_mailer.php', __DIR__ . '/../includes/mailer/users_mailer.php',

View File

@ -2,6 +2,7 @@
use Carbon\Carbon; use Carbon\Carbon;
use Engelsystem\Database\DB; use Engelsystem\Database\DB;
use Engelsystem\Events\Listener\OAuth2;
use Engelsystem\Models\OAuth; use Engelsystem\Models\OAuth;
use Engelsystem\Models\User\Contact; use Engelsystem\Models\User\Contact;
use Engelsystem\Models\User\PersonalData; use Engelsystem\Models\User\PersonalData;
@ -55,6 +56,16 @@ function guest_register()
$angel_types_source = AngelTypes(); $angel_types_source = AngelTypes();
$angel_types = []; $angel_types = [];
if (!empty($session->get('oauth2_groups'))) {
/** @var OAuth2 $oauth */
$oauth = app()->get(OAuth2::class);
$ssoTeams = $oauth->getSsoTeams($session->get('oauth2_connect_provider'));
foreach ($ssoTeams as $name => $team) {
if (in_array($name, $session->get('oauth2_groups'))) {
$selected_angel_types[] = $team['id'];
}
}
}
foreach ($angel_types_source as $angel_type) { foreach ($angel_types_source as $angel_type) {
$angel_types[$angel_type['id']] = $angel_type['name'] $angel_types[$angel_type['id']] = $angel_type['name']
. ($angel_type['restricted'] ? ' (' . __('Requires introduction') . ')' : ''); . ($angel_type['restricted'] ? ' (' . __('Requires introduction') . ')' : '');

View File

@ -106,6 +106,7 @@ class OAuthController extends BaseController
throw new HttpNotFound('oauth.invalid-state'); throw new HttpNotFound('oauth.invalid-state');
} }
$accessToken = null;
try { try {
$accessToken = $provider->getAccessToken( $accessToken = $provider->getAccessToken(
'authorization_code', 'authorization_code',
@ -114,21 +115,15 @@ class OAuthController extends BaseController
] ]
); );
} catch (IdentityProviderException $e) { } catch (IdentityProviderException $e) {
$response = $e->getResponseBody(); $this->handleOAuthError($e, $providerName);
$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');
} }
$resourceOwner = $provider->getResourceOwner($accessToken); $resourceOwner = null;
try {
$resourceOwner = $provider->getResourceOwner($accessToken);
} catch (IdentityProviderException $e) {
$this->handleOAuthError($e, $providerName);
}
$resourceId = $resourceOwner->getId(); $resourceId = $resourceOwner->getId();
/** @var OAuth|null $oauth */ /** @var OAuth|null $oauth */
@ -180,7 +175,11 @@ class OAuthController extends BaseController
$config = $this->config->get('oauth')[$providerName]; $config = $this->config->get('oauth')[$providerName];
$userdata = new Collection($resourceOwner->toArray()); $userdata = new Collection($resourceOwner->toArray());
if (!$oauth) { if (!$oauth) {
return $this->redirectRegisterOrThrowNotFound( if (!$this->config->get('registration_enabled')) {
throw new HttpNotFound('oauth.not-found');
}
return $this->redirectRegister(
$providerName, $providerName,
$resourceOwner->getId(), $resourceOwner->getId(),
$accessToken, $accessToken,
@ -193,7 +192,10 @@ class OAuthController extends BaseController
$this->handleArrive($providerName, $oauth, $resourceOwner); $this->handleArrive($providerName, $oauth, $resourceOwner);
} }
return $this->authController->loginUser($oauth->user); $response = $this->authController->loginUser($oauth->user);
event('oauth2.login', ['provider' => $providerName, 'data' => $userdata]);
return $response;
} }
/** /**
@ -308,6 +310,28 @@ class OAuthController extends BaseController
); );
} }
/**
* @param IdentityProviderException $e
* @param string $providerName
*
* @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');
}
/** /**
* @param string $providerName * @param string $providerName
* @param string $providerUserIdentifier * @param string $providerUserIdentifier
@ -317,19 +341,15 @@ class OAuthController extends BaseController
* *
* @return Response * @return Response
*/ */
protected function redirectRegisterOrThrowNotFound( protected function redirectRegister(
string $providerName, string $providerName,
string $providerUserIdentifier, string $providerUserIdentifier,
AccessTokenInterface $accessToken, AccessTokenInterface $accessToken,
array $config, array $config,
Collection $userdata Collection $userdata
): Response { ): Response {
if (!$this->config->get('registration_enabled')) {
throw new HttpNotFound('oauth.not-found');
}
$config = array_merge( $config = array_merge(
['username' => null, 'email' => null, 'first_name' => null, 'last_name' => null], ['username' => null, 'email' => null, 'first_name' => null, 'last_name' => null, 'groups' => null],
$config $config
); );
$this->session->set( $this->session->set(
@ -341,6 +361,7 @@ class OAuthController extends BaseController
'last_name' => $userdata->get($config['last_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_connect_provider', $providerName);
$this->session->set('oauth2_user_id', $providerUserIdentifier); $this->session->set('oauth2_user_id', $providerUserIdentifier);

View File

@ -5,6 +5,7 @@ namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Config\Config; use Engelsystem\Config\Config;
use Engelsystem\Controllers\AuthController; use Engelsystem\Controllers\AuthController;
use Engelsystem\Controllers\OAuthController; use Engelsystem\Controllers\OAuthController;
use Engelsystem\Events\EventDispatcher;
use Engelsystem\Helpers\Authenticator; use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Exceptions\HttpNotFound; use Engelsystem\Http\Exceptions\HttpNotFound;
use Engelsystem\Http\Redirector; use Engelsystem\Http\Redirector;
@ -124,6 +125,11 @@ class OAuthControllerTest extends TestCase
); );
$this->setExpects($provider, 'getResourceOwner', [$accessToken], $resourceOwner, $this->atLeastOnce()); $this->setExpects($provider, 'getResourceOwner', [$accessToken], $resourceOwner, $this->atLeastOnce());
/** @var EventDispatcher|MockObject $event */
$event = $this->createMock(EventDispatcher::class);
$this->app->instance('events.dispatcher', $event);
$this->setExpects($event, 'dispatch', ['oauth2.login'], null, 4);
$this->authController->expects($this->atLeastOnce()) $this->authController->expects($this->atLeastOnce())
->method('loginUser') ->method('loginUser')
->willReturnCallback(function (User $user) { ->willReturnCallback(function (User $user) {
@ -241,18 +247,38 @@ class OAuthControllerTest extends TestCase
/** /**
* @covers \Engelsystem\Controllers\OAuthController::index * @covers \Engelsystem\Controllers\OAuthController::index
* @covers \Engelsystem\Controllers\OAuthController::handleOAuthError
*/ */
public function testIndexProviderError() public function testIndexProviderError()
{ {
/** @var AccessToken|MockObject $accessToken */
$accessToken = $this->createMock(AccessToken::class);
$thrown = false;
/** @var GenericProvider|MockObject $provider */ /** @var GenericProvider|MockObject $provider */
$provider = $this->createMock(GenericProvider::class); $provider = $this->createMock(GenericProvider::class);
$provider->expects($this->once()) $provider->expects($this->exactly(2))
->method('getAccessToken') ->method('getAccessToken')
->with('authorization_code', ['code' => 'lorem-ipsum-code']) ->with('authorization_code', ['code' => 'lorem-ipsum-code'])
->willReturnCallback(function () use (&$thrown, $accessToken) {
if (!$thrown) {
$thrown = true;
throw new IdentityProviderException(
'Oops',
42,
['error' => 'some_error', 'error_description' => 'Some kind of error']
);
}
return $accessToken;
});
$provider->expects($this->once())
->method('getResourceOwner')
->with($accessToken)
->willThrowException(new IdentityProviderException( ->willThrowException(new IdentityProviderException(
'Oops', 'Something\'s wrong!',
42, 1337,
['error' => 'some_error', 'error_description' => 'Some kind of error'] '500 Internal server error'
)); ));
$this->session->set('oauth2_state', 'some-internal-state'); $this->session->set('oauth2_state', 'some-internal-state');
@ -263,8 +289,9 @@ class OAuthControllerTest extends TestCase
->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-internal-state']); ->withQueryParams(['code' => 'lorem-ipsum-code', 'state' => 'some-internal-state']);
$controller = $this->getMock(['getProvider']); $controller = $this->getMock(['getProvider']);
$this->setExpects($controller, 'getProvider', ['testprovider'], $provider); $this->setExpects($controller, 'getProvider', ['testprovider'], $provider, 2);
// Invalid state
$exception = null; $exception = null;
try { try {
$controller->index($request); $controller->index($request);
@ -276,6 +303,18 @@ class OAuthControllerTest extends TestCase
$this->log->hasErrorThatContains('some_error'); $this->log->hasErrorThatContains('some_error');
$this->assertNotNull($exception, 'Exception not thrown'); $this->assertNotNull($exception, 'Exception not thrown');
$this->assertEquals('oauth.provider-error', $exception->getMessage()); $this->assertEquals('oauth.provider-error', $exception->getMessage());
// Error while getting data
$exception = null;
try {
$controller->index($request);
} catch (HttpNotFound $e) {
$exception = $e;
}
$this->log->hasErrorThatContains('500');
$this->assertNotNull($exception, 'Exception not thrown');
$this->assertEquals('oauth.provider-error', $exception->getMessage());
} }
/** /**
@ -325,7 +364,7 @@ class OAuthControllerTest extends TestCase
/** /**
* @covers \Engelsystem\Controllers\OAuthController::index * @covers \Engelsystem\Controllers\OAuthController::index
* @covers \Engelsystem\Controllers\OAuthController::redirectRegisterOrThrowNotFound * @covers \Engelsystem\Controllers\OAuthController::redirectRegister
*/ */
public function testIndexRedirectRegister() public function testIndexRedirectRegister()
{ {

View File

@ -27,6 +27,10 @@ abstract class TestCase extends PHPUnitTestCase
$times = $this->once(); $times = $this->once();
} }
if (is_int($times)) {
$times = $this->exactly($times);
}
$invocation = $object->expects($times) $invocation = $object->expects($times)
->method($method); ->method($method);