diff --git a/config/app.php b/config/app.php index 3efab0c4..65c2648b 100644 --- a/config/app.php +++ b/config/app.php @@ -66,5 +66,7 @@ return [ // or $function // ] 'news.created' => \Engelsystem\Events\Listener\News::class . '@created', + + 'oauth2.login' => \Engelsystem\Events\Listener\OAuth2::class . '@login', ], ]; diff --git a/config/config.default.php b/config/config.default.php index e7f8957d..a9963288 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -105,6 +105,14 @@ return [ 'hidden' => false, // Mark user as arrived when using this provider (optional) '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 + ], ], */ ], diff --git a/includes/helper/oauth_helper.php b/includes/helper/oauth_helper.php new file mode 100644 index 00000000..aec444f2 --- /dev/null +++ b/includes/helper/oauth_helper.php @@ -0,0 +1,124 @@ +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; + } +} diff --git a/includes/includes.php b/includes/includes.php index b94ef70d..2756fd65 100644 --- a/includes/includes.php +++ b/includes/includes.php @@ -60,6 +60,7 @@ $includeFiles = [ __DIR__ . '/../includes/helper/legacy_helper.php', __DIR__ . '/../includes/helper/message_helper.php', __DIR__ . '/../includes/helper/email_helper.php', + __DIR__ . '/../includes/helper/oauth_helper.php', __DIR__ . '/../includes/mailer/shifts_mailer.php', __DIR__ . '/../includes/mailer/users_mailer.php', diff --git a/includes/pages/guest_login.php b/includes/pages/guest_login.php index 7deb4619..370e15a5 100644 --- a/includes/pages/guest_login.php +++ b/includes/pages/guest_login.php @@ -2,6 +2,7 @@ use Carbon\Carbon; use Engelsystem\Database\DB; +use Engelsystem\Events\Listener\OAuth2; use Engelsystem\Models\OAuth; use Engelsystem\Models\User\Contact; use Engelsystem\Models\User\PersonalData; @@ -55,6 +56,16 @@ function guest_register() $angel_types_source = AngelTypes(); $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) { $angel_types[$angel_type['id']] = $angel_type['name'] . ($angel_type['restricted'] ? ' (' . __('Requires introduction') . ')' : ''); diff --git a/src/Controllers/OAuthController.php b/src/Controllers/OAuthController.php index fd83688a..06542ad5 100644 --- a/src/Controllers/OAuthController.php +++ b/src/Controllers/OAuthController.php @@ -106,6 +106,7 @@ class OAuthController extends BaseController throw new HttpNotFound('oauth.invalid-state'); } + $accessToken = null; try { $accessToken = $provider->getAccessToken( 'authorization_code', @@ -114,21 +115,15 @@ class OAuthController extends BaseController ] ); } catch (IdentityProviderException $e) { - $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'); + $this->handleOAuthError($e, $providerName); } - $resourceOwner = $provider->getResourceOwner($accessToken); + $resourceOwner = null; + try { + $resourceOwner = $provider->getResourceOwner($accessToken); + } catch (IdentityProviderException $e) { + $this->handleOAuthError($e, $providerName); + } $resourceId = $resourceOwner->getId(); /** @var OAuth|null $oauth */ @@ -180,7 +175,11 @@ class OAuthController extends BaseController $config = $this->config->get('oauth')[$providerName]; $userdata = new Collection($resourceOwner->toArray()); if (!$oauth) { - return $this->redirectRegisterOrThrowNotFound( + if (!$this->config->get('registration_enabled')) { + throw new HttpNotFound('oauth.not-found'); + } + + return $this->redirectRegister( $providerName, $resourceOwner->getId(), $accessToken, @@ -193,7 +192,10 @@ class OAuthController extends BaseController $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 $providerUserIdentifier @@ -317,19 +341,15 @@ class OAuthController extends BaseController * * @return Response */ - protected function redirectRegisterOrThrowNotFound( + protected function redirectRegister( string $providerName, string $providerUserIdentifier, AccessTokenInterface $accessToken, array $config, Collection $userdata ): Response { - if (!$this->config->get('registration_enabled')) { - throw new HttpNotFound('oauth.not-found'); - } - $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 ); $this->session->set( @@ -341,6 +361,7 @@ class OAuthController extends BaseController '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); diff --git a/tests/Unit/Controllers/OAuthControllerTest.php b/tests/Unit/Controllers/OAuthControllerTest.php index 66181f55..2d2caaaf 100644 --- a/tests/Unit/Controllers/OAuthControllerTest.php +++ b/tests/Unit/Controllers/OAuthControllerTest.php @@ -5,6 +5,7 @@ namespace Engelsystem\Test\Unit\Controllers; use Engelsystem\Config\Config; use Engelsystem\Controllers\AuthController; use Engelsystem\Controllers\OAuthController; +use Engelsystem\Events\EventDispatcher; use Engelsystem\Helpers\Authenticator; use Engelsystem\Http\Exceptions\HttpNotFound; use Engelsystem\Http\Redirector; @@ -124,6 +125,11 @@ class OAuthControllerTest extends TestCase ); $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()) ->method('loginUser') ->willReturnCallback(function (User $user) { @@ -241,18 +247,38 @@ class OAuthControllerTest extends TestCase /** * @covers \Engelsystem\Controllers\OAuthController::index + * @covers \Engelsystem\Controllers\OAuthController::handleOAuthError */ public function testIndexProviderError() { + /** @var AccessToken|MockObject $accessToken */ + $accessToken = $this->createMock(AccessToken::class); + + $thrown = false; /** @var GenericProvider|MockObject $provider */ $provider = $this->createMock(GenericProvider::class); - $provider->expects($this->once()) + $provider->expects($this->exactly(2)) ->method('getAccessToken') ->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( - 'Oops', - 42, - ['error' => 'some_error', 'error_description' => 'Some kind of error'] + 'Something\'s wrong!', + 1337, + '500 Internal server error' )); $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']); $controller = $this->getMock(['getProvider']); - $this->setExpects($controller, 'getProvider', ['testprovider'], $provider); + $this->setExpects($controller, 'getProvider', ['testprovider'], $provider, 2); + // Invalid state $exception = null; try { $controller->index($request); @@ -276,6 +303,18 @@ class OAuthControllerTest extends TestCase $this->log->hasErrorThatContains('some_error'); $this->assertNotNull($exception, 'Exception not thrown'); $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::redirectRegisterOrThrowNotFound + * @covers \Engelsystem\Controllers\OAuthController::redirectRegister */ public function testIndexRedirectRegister() { diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 27357d29..9f7bbf61 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -27,6 +27,10 @@ abstract class TestCase extends PHPUnitTestCase $times = $this->once(); } + if (is_int($times)) { + $times = $this->exactly($times); + } + $invocation = $object->expects($times) ->method($method);