diff --git a/README.md b/README.md index a3daa35..3790078 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ This package turns your application into Service Provider with the support of mu ## Requirements -- Laravel 5.4+ -- PHP 7.0+ +- Laravel 8.0+ +- PHP 7.4+ ## Getting Started @@ -26,22 +26,6 @@ This package turns your application into Service Provider with the support of mu composer require 24slides/laravel-saml2 ``` -If you are using Laravel 5.5 and higher, the service provider will be automatically registered. - -For older versions, you have to add the service provider and alias to your `config/app.php`: - -```php -'providers' => [ - ... - Slides\Saml2\ServiceProvider::class, -] - -'alias' => [ - ... - 'Saml2' => Slides\Saml2\Facades\Auth::class, -] -``` - ##### Step 2. Publish the configuration file. ``` @@ -66,18 +50,19 @@ When request comes to an application, the middleware parses UUID and resolves th You can easily manage tenants using the following console commands: -- `artisan saml2:create-tenant` -- `artisan saml2:update-tenant` -- `artisan saml2:delete-tenant` -- `artisan saml2:restore-tenant` -- `artisan saml2:list-tenants` -- `artisan saml2:tenant-credentials` +- `artisan saml2:idp-create` +- `artisan saml2:idp-update` +- `artisan saml2:idp-delete` +- `artisan saml2:idp-restore` +- `artisan saml2:idp-list` +- `artisan saml2:idp-get` > To learn their options, run a command with `-h` parameter. Each Tenant has the following attributes: -- **UUID** — a unique identifier that allows to resolve a tenannt and configure SP correspondingly +- **UUID** — a unique identifier that allows to resolve an Identity Provider and configure SP correspondingly +- **Tenant** — an optional morph relation to your custom model that binds IdP with your application entity (fx. user, organisation, etc.) - **Key** — a custom key to use for application needs - **Entity ID** — [Identity Provider Entity ID](https://spaces.at.internet2.edu/display/InCFederation/Entity+IDs) - **Login URL** — Identity Provider Single Sign On URL @@ -85,6 +70,24 @@ Each Tenant has the following attributes: - **x509 certificate** — The certificate provided by Identity Provider in **base64** format - **Metadata** — Custom parameters for your application needs +```php +use \Slides\Saml2\Concerns\IdentityProviderAuthenticatable; + +class Organization extends \Illuminate\Database\Eloquent\Model +{ + use IdentityProviderAuthenticatable; +} + +$organization->identityProvider->loginUrl(); +$organization->identityProvider->sessions(); + +Saml2::withIdentityProvider($organization->identityProvider) + ->route('custom.route'); + +Saml2::withIdentityProvider($organization->identityProvider) + ->url('custom.route'); +``` + #### Default routes The following routes are registered by default: @@ -283,7 +286,7 @@ If you discover any security related issues, please email **brezzhnev@gmail.com* ## Credits - [aacotroneo][link-original-author] -- [brezzhnev][link-author] +- [breart][link-author] - [All Contributors][link-contributors] ## License diff --git a/composer.json b/composer.json index 711898b..cd0495d 100644 --- a/composer.json +++ b/composer.json @@ -12,13 +12,14 @@ } ], "require": { - "php": ">=7.1", + "php": ">=7.3", "ext-openssl": "*", - "illuminate/console": "~5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "illuminate/database": "~5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "illuminate/support": "~5.4|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "onelogin/php-saml": "^3.0|^4.0", - "ramsey/uuid": "^3.8|^4.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0", + "illuminate/database": "^8.0|^9.0|^10.0|^11.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0", + "onelogin/php-saml": "^4.1", + "ramsey/uuid": "^4.0", + "cerbero/command-validator": "^2.5" }, "require-dev": { "mockery/mockery": "^0.9.9", diff --git a/config/saml2.php b/config/saml2.php index 541f9fb..1309dc6 100644 --- a/config/saml2.php +++ b/config/saml2.php @@ -4,14 +4,48 @@ /* |-------------------------------------------------------------------------- - | Tenant Model + | Identity Provider Model |-------------------------------------------------------------------------- | - | This will allow you to override the tenant model with your own. + | This will allow you to override the Identity Provider model with your own. | */ - 'tenantModel' => \Slides\Saml2\Models\Tenant::class, + 'idpModel' => \Slides\Saml2\Models\IdentityProvider::class, + + /* + |-------------------------------------------------------------------------- + | Classes that implement Identity Provider and config resolution logic. + |-------------------------------------------------------------------------- + | + | Here you may customize the way Identity Provider gets resolved, + | as well as configuration adjustments of the SP once IdP is resolved. + | + */ + + 'resolvers' => [ + 'idp' => \Slides\Saml2\Resolvers\IdentityProviderResolver::class, + 'config' => \Slides\Saml2\Resolvers\ConfigResolver::class, + ], + + /* + |-------------------------------------------------------------------------- + | User authentication settings. + |-------------------------------------------------------------------------- + | + | Here you may specify the settings for default (basic) user authorization. + | + | You can extend this functionality by implementing your own user resolver. + | Or completely disable it and use Slides\Saml2\Events\SignedIn event instead. + | + */ + + 'auth' => [ + 'enabled' => env('SAML2_AUTHORIZE_USER', true), + 'resolver' => \Slides\Saml2\Resolvers\UserResolver::class, + 'userModel' => \App\Models\User::class, + 'createUser' => env('SAML2_CREATE_USER', true), + ], /* |-------------------------------------------------------------------------- @@ -170,7 +204,7 @@ | */ - 'x509cert' => env('SAML2_SP_CERT_x509',''), + 'x509cert' => env('SAML2_SP_CERT_X509',''), 'privateKey' => env('SAML2_SP_CERT_PRIVATEKEY',''), /* @@ -393,5 +427,5 @@ | This will allow you to disable or enable the default migrations of the package. | */ - 'load_migrations' => true, + 'loadMigrations' => true, ]; diff --git a/database/migrations/2023_07_21_203815_add_model_morph_columns_to_saml2_tenants_table.php b/database/migrations/2023_07_21_203815_add_model_morph_columns_to_saml2_tenants_table.php new file mode 100644 index 0000000..dae596f --- /dev/null +++ b/database/migrations/2023_07_21_203815_add_model_morph_columns_to_saml2_tenants_table.php @@ -0,0 +1,36 @@ +nullableMorphs('tenant'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('saml2_identity_providers', function (Blueprint $table) { + $table->dropMorphs('tenant'); + }); + + Schema::rename('saml2_identity_providers', 'saml2_tenants'); + } +} diff --git a/database/migrations/2023_07_21_205057_create_saml2_sessions_table.php b/database/migrations/2023_07_21_205057_create_saml2_sessions_table.php new file mode 100644 index 0000000..bd5403d --- /dev/null +++ b/database/migrations/2023_07_21_205057_create_saml2_sessions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('idp_id')->constrained('saml2_identity_providers'); + $table->foreignId('user_id')->nullable(); + $table->json('payload'); + $table->timestamps('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('saml2_sessions'); + } +} diff --git a/database/migrations/2023_11_26_121929_alter_nullable_idp_columns_in_saml2_tenants_table.php b/database/migrations/2023_11_26_121929_alter_nullable_idp_columns_in_saml2_tenants_table.php new file mode 100644 index 0000000..452d8bd --- /dev/null +++ b/database/migrations/2023_11_26_121929_alter_nullable_idp_columns_in_saml2_tenants_table.php @@ -0,0 +1,40 @@ +string('idp_entity_id')->nullable()->change(); + $table->string('idp_login_url')->nullable()->change(); + $table->string('idp_logout_url')->nullable()->change(); + $table->string('name_id_format')->nullable()->change(); + $table->text('idp_x509_cert')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('saml2_identity_providers', function (Blueprint $table) { + $table->string('idp_entity_id')->nullable(false)->change(); + $table->string('idp_login_url')->nullable(false)->change(); + $table->string('idp_logout_url')->nullable(false)->change(); + $table->string('name_id_format')->nullable(false)->change(); + $table->text('idp_x509_cert')->nullable(false)->change(); + }); + } +} diff --git a/src/Auth.php b/src/Auth.php index ce94894..d922f67 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -4,14 +4,9 @@ use OneLogin\Saml2\Auth as OneLoginAuth; use OneLogin\Saml2\Error as OneLoginError; +use Slides\Saml2\Contracts\IdentityProvidable; use Slides\Saml2\Events\SignedOut; -use Slides\Saml2\Models\Tenant; -/** - * Class Auth - * - * @package Slides\Saml2 - */ class Auth { /** @@ -19,25 +14,25 @@ class Auth * * @var OneLoginAuth */ - protected $base; + protected OneLoginAuth $base; /** * The resolved tenant. * - * @var Tenant + * @var IdentityProvidable */ - protected $tenant; + protected IdentityProvidable $idp; /** * Auth constructor. * * @param OneLoginAuth $auth - * @param Tenant $tenant + * @param IdentityProvidable $idp */ - public function __construct(OneLoginAuth $auth, Tenant $tenant) + public function __construct(OneLoginAuth $auth, IdentityProvidable $idp) { $this->base = $auth; - $this->tenant = $tenant; + $this->idp = $idp; } /** @@ -45,7 +40,7 @@ public function __construct(OneLoginAuth $auth, Tenant $tenant) * * @return bool */ - public function isAuthenticated() + public function isAuthenticated(): bool { return $this->base->isAuthenticated(); } @@ -55,17 +50,17 @@ public function isAuthenticated() * * @return Saml2User */ - public function getSaml2User() + public function getSaml2User(): Saml2User { - return new Saml2User($this->base, $this->tenant); + return new Saml2User($this->base, $this->idp); } /** * The ID of the last message processed. * - * @return String + * @return string */ - public function getLastMessageId() + public function getLastMessageId(): string { return $this->base->getLastMessageId(); } @@ -88,13 +83,13 @@ public function getLastMessageId() * @throws OneLoginError */ public function login( - $returnTo = null, - $parameters = array(), - $forceAuthn = false, - $isPassive = false, - $stay = false, - $setNameIdPolicy = true - ) + string $returnTo = null, + array $parameters = [], + bool $forceAuthn = false, + bool $isPassive = false, + bool $stay = false, + bool $setNameIdPolicy = true + ): ?string { return $this->base->login($returnTo, $parameters, $forceAuthn, $isPassive, $stay, $setNameIdPolicy); } @@ -110,18 +105,18 @@ public function login( * @param bool $stay True if we want to stay (returns the url string) False to redirect * @param string|null $nameIdNameQualifier The NameID NameQualifier will be set in the LogoutRequest. * - * @return string|null If $stay is True, it return a string with the SLO URL + LogoutRequest + parameters + * @return string|null If $stay is true, it returns a string with the SLO URL + LogoutRequest + parameters * * @throws OneLoginError */ public function logout( - $returnTo = null, - $nameId = null, - $sessionIndex = null, - $nameIdFormat = null, - $stay = false, - $nameIdNameQualifier = null - ) + string $returnTo = null, + string $nameId = null, + string $sessionIndex = null, + string $nameIdFormat = null, + bool $stay = false, + string $nameIdNameQualifier = null + ): ?string { $auth = $this->base; @@ -136,13 +131,13 @@ public function logout( * @throws OneLoginError * @throws \OneLogin\Saml2\ValidationError */ - public function acs() + public function acs(): ?array { $this->base->processResponse(); $errors = $this->base->getErrors(); - if (!empty($errors)) { + if (!$errors) { return $errors; } @@ -156,7 +151,7 @@ public function acs() /** * Process the SAML Logout Response / Logout Request sent by the IdP. * - * Returns an array with errors if it can not logout. + * Returns an array with errors if it cannot log out. * * @param bool $retrieveParametersFromServer * @@ -164,7 +159,7 @@ public function acs() * * @throws \OneLogin\Saml2\Error */ - public function sls($retrieveParametersFromServer = false) + public function sls(bool $retrieveParametersFromServer = false): array { $this->base->processSLO(false, null, $retrieveParametersFromServer, function () { event(new SignedOut()); @@ -184,13 +179,13 @@ public function sls($retrieveParametersFromServer = false) * @throws \Exception * @throws \InvalidArgumentException If metadata is not correctly set */ - public function getMetadata() + public function getMetadata(): string { $settings = $this->base->getSettings(); $metadata = $settings->getSPMetadata(); $errors = $settings->validateMetadata($metadata); - if (!count($errors)) { + if (!$errors) { return $metadata; } @@ -203,11 +198,9 @@ public function getMetadata() /** * Get the last error reason from \OneLogin_Saml2_Auth, useful for error debugging. * - * @see \OneLogin_Saml2_Auth::getLastErrorReason() - * - * @return string + * @return string|null */ - public function getLastErrorReason() + public function getLastErrorReason(): ?string { return $this->base->getLastErrorReason(); } @@ -217,7 +210,7 @@ public function getLastErrorReason() * * @return OneLoginAuth */ - public function getBase() + public function getBase(): OneLoginAuth { return $this->base; } @@ -225,22 +218,22 @@ public function getBase() /** * Set a tenant * - * @param Tenant $tenant + * @param IdentityProvidable $idp * * @return void */ - public function setTenant(Tenant $tenant) + public function setIdp(IdentityProvidable $idp) { - $this->tenant = $tenant; + $this->idp = $idp; } /** * Get a resolved tenant. * - * @return Tenant|null + * @return IdentityProvidable|null */ - public function getTenant() + public function getIdp(): ?IdentityProvidable { - return $this->tenant; + return $this->idp; } } diff --git a/src/Commands/Create.php b/src/Commands/Create.php new file mode 100644 index 0000000..deea6bf --- /dev/null +++ b/src/Commands/Create.php @@ -0,0 +1,140 @@ +tenants = $tenants; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $metadata = ConsoleHelper::stringToArray($this->option('metadata')); + + $model = config('saml2.idpModel'); + $tenant = new $model([ + 'key' => $this->option('key'), + 'uuid' => Uuid::uuid4(), + 'idp_entity_id' => $this->option('entityId'), + 'idp_login_url' => $this->option('loginUrl'), + 'idp_logout_url' => $this->option('logoutUrl'), + 'idp_x509_cert' => $this->option('x509cert'), + 'relay_state_url' => $this->option('relayStateUrl'), + 'name_id_format' => $this->option('nameIdFormat'), + 'metadata' => $metadata, + ]); + + if(!$tenant->save()) { + $this->error('IdentityProvidable cannot be saved.'); + return; + } + + $this->info("The tenant #$tenant->id ($tenant->uuid) was successfully created."); + + $this->renderTenantCredentials($tenant); + + $this->output->note('You can share this info with the Identity Provider to retrieve the metadata, and then finish the setup by running:'); + $this->output->block( + sprintf('php artisan saml2:idp-update %d \ + --entityId="%s" \ + --loginUrl="%s" \ + --logoutUrl="%s" \ + --x509cert="%s"', + $tenant->id, + '(received Entity ID)', + '(received SSO URL)', + '(received Logout URL)', + '(received x509 certificate)' + ) + ); + } + + /** + * Validation rules for the user input. + * + * @return array + */ + protected function rules(): array + { + return [ + 'key' => ['string', new Unique(config('saml2.idpModel'), 'key')], + 'entityId' => 'string', + 'loginUrl' => 'string|url', + 'logoutUrl' => 'string|url', + 'x509cert' => 'string', + 'relayStateUrl' => 'string|url', + 'metadata' => 'string', + 'nameIdFormat' => ['string', new In($this->nameIdFormatValues())] + ]; + } + + /** + * Get the values for the name ID format rule. + * + * @return string[] + */ + protected function nameIdFormatValues(): array + { + return [ + 'persistent', + 'transient', + 'emailAddress', + 'unspecified', + 'X509SubjectName', + 'WindowsDomainQualifiedName', + 'kerberos', + 'entity' + ]; + } +} diff --git a/src/Commands/CreateTenant.php b/src/Commands/CreateTenant.php deleted file mode 100644 index eb8169d..0000000 --- a/src/Commands/CreateTenant.php +++ /dev/null @@ -1,121 +0,0 @@ -tenants = $tenants; - - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return void - */ - public function handle() - { - if (!$entityId = $this->option('entityId')) { - $this->error('Entity ID must be passed as an option --entityId'); - return; - } - - if (!$loginUrl = $this->option('loginUrl')) { - $this->error('Login URL must be passed as an option --loginUrl'); - return; - } - - if (!$logoutUrl = $this->option('logoutUrl')) { - $this->error('Logout URL must be passed as an option --logoutUrl'); - return; - } - - if (!$x509cert = $this->option('x509cert')) { - $this->error('x509 certificate (base64) must be passed as an option --x509cert'); - return; - } - - $key = $this->option('key'); - $metadata = ConsoleHelper::stringToArray($this->option('metadata')); - - if($key && ($tenant = $this->tenants->findByKey($key))) { - $this->renderTenants($tenant, 'Already found tenant(s) using this key'); - $this->error( - 'Cannot create a tenant because the key is already being associated with other tenants.' - . PHP_EOL . 'Firstly, delete tenant(s) or try to create with another with another key.' - ); - - return; - } - - $class = config('saml2.tenantModel', Tenant::class); - $tenant = new $class([ - 'key' => $key, - 'uuid' => \Ramsey\Uuid\Uuid::uuid4(), - 'idp_entity_id' => $entityId, - 'idp_login_url' => $loginUrl, - 'idp_logout_url' => $logoutUrl, - 'idp_x509_cert' => $x509cert, - 'relay_state_url' => $this->option('relayStateUrl'), - 'name_id_format' => $this->resolveNameIdFormat(), - 'metadata' => $metadata, - ]); - - if(!$tenant->save()) { - $this->error('Tenant cannot be saved.'); - return; - } - - $this->info("The tenant #{$tenant->id} ({$tenant->uuid}) was successfully created."); - - $this->renderTenantCredentials($tenant); - - $this->output->newLine(); - } -} \ No newline at end of file diff --git a/src/Commands/ListTenants.php b/src/Commands/ListAll.php similarity index 79% rename from src/Commands/ListTenants.php rename to src/Commands/ListAll.php index 28b07b5..a0b5f59 100644 --- a/src/Commands/ListTenants.php +++ b/src/Commands/ListAll.php @@ -4,12 +4,7 @@ use Slides\Saml2\Repositories\TenantRepository; -/** - * Class ListTenants - * - * @package Slides\Saml2\Commands - */ -class ListTenants extends \Illuminate\Console\Command +class ListAll extends \Illuminate\Console\Command { use RendersTenants; @@ -18,7 +13,7 @@ class ListTenants extends \Illuminate\Console\Command * * @var string */ - protected $signature = 'saml2:list-tenants'; + protected $signature = 'saml2:idp-list'; /** * The console command description. @@ -30,7 +25,7 @@ class ListTenants extends \Illuminate\Console\Command /** * @var TenantRepository */ - protected $tenants; + protected TenantRepository $tenants; /** * DeleteTenant constructor. @@ -53,11 +48,11 @@ public function handle() { $tenants = $this->tenants->all(); - if($tenants->isEmpty()) { + if ($tenants->isEmpty()) { $this->info('No tenants found'); return; } $this->renderTenants($tenants); } -} \ No newline at end of file +} diff --git a/src/Commands/RendersTenants.php b/src/Commands/RendersTenants.php index 5477344..779cf0e 100644 --- a/src/Commands/RendersTenants.php +++ b/src/Commands/RendersTenants.php @@ -2,28 +2,23 @@ namespace Slides\Saml2\Commands; -use Slides\Saml2\Models\Tenant; +use Slides\Saml2\Contracts\IdentityProvidable; use Illuminate\Support\Str; -/** - * Class CreateTenant - * - * @package Slides\Saml2\Commands - */ trait RendersTenants { /** * Render tenants in a table. * - * @param \Slides\Saml2\Models\Tenant|\Illuminate\Support\Collection $tenants + * @param IdentityProvidable|\Illuminate\Support\Collection $tenants * @param string|null $title * * @return void */ protected function renderTenants($tenants, string $title = null) { - /** @var \Slides\Saml2\Models\Tenant[]|\Illuminate\Database\Eloquent\Collection $tenants */ - $tenants = $tenants instanceof Tenant + /** @var \Slides\Saml2\Models\IdentityProvider[]|\Illuminate\Database\Eloquent\Collection $tenants */ + $idps = $tenants instanceof IdentityProvidable ? collect([$tenants]) : $tenants; @@ -48,13 +43,13 @@ protected function renderTenants($tenants, string $title = null) } /** - * Get a columns of the Tenant. + * Get a columns of the IdentityProvidable. * - * @param \Slides\Saml2\Models\Tenant $tenant + * @param IdentityProvidable $tenant * * @return array */ - protected function getTenantColumns(Tenant $tenant) + protected function getTenantColumns(IdentityProvidable $tenant) { return [ 'ID' => $tenant->id, @@ -74,22 +69,22 @@ protected function getTenantColumns(Tenant $tenant) } /** - * Render a tenant credentials. + * Render IDP credentials. * - * @param \Slides\Saml2\Models\Tenant $tenant + * @param IdentityProvidable $idp * * @return void */ - protected function renderTenantCredentials(Tenant $tenant) + protected function renderTenantCredentials(IdentityProvidable $idp) { - $this->output->section('Credentials for the tenant'); + $this->output->section('Identity Provider credentials'); $this->getOutput()->text([ - 'Identifier (Entity ID): ' . route('saml.metadata', ['uuid' => $tenant->uuid]) . '', - 'Reply URL (Assertion Consumer Service URL): ' . route('saml.acs', ['uuid' => $tenant->uuid]) . '', - 'Sign on URL: ' . route('saml.login', ['uuid' => $tenant->uuid]) . '', - 'Logout URL: ' . route('saml.logout', ['uuid' => $tenant->uuid]) . '', - 'Relay State: ' . ($tenant->relay_state_url ?: config('saml2.loginRoute')) . ' (optional)' + 'Identifier (Entity ID): ' . route('saml.metadata', ['uuid' => $idp->uuid]) . '', + 'Reply URL (Assertion Consumer Service URL): ' . route('saml.acs', ['uuid' => $idp->uuid]) . '', + 'Sign on URL: ' . route('saml.login', ['uuid' => $idp->uuid]) . '', + 'Logout URL: ' . route('saml.logout', ['uuid' => $idp->uuid]) . '', + 'Relay State: ' . ($idp->relay_state_url ?: config('saml2.loginRoute')) . ' (optional)' ]); } @@ -100,7 +95,7 @@ protected function renderTenantCredentials(Tenant $tenant) * * @return string */ - protected function renderArray(array $array) + protected function renderArray(array $array): string { $lines = []; @@ -110,4 +105,4 @@ protected function renderArray(array $array) return implode(PHP_EOL, $lines); } -} \ No newline at end of file +} diff --git a/src/Commands/Update.php b/src/Commands/Update.php new file mode 100644 index 0000000..0c1a67b --- /dev/null +++ b/src/Commands/Update.php @@ -0,0 +1,164 @@ +repository = $repository; + + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $tenant = $this->repository->findById($this->argument('id')); + + $tenant->update(array_filter([ + 'key' => $this->option('key'), + 'idp_entity_id' => $this->option('entityId'), + 'idp_login_url' => $this->option('loginUrl'), + 'idp_logout_url' => $this->option('logoutUrl'), + 'idp_x509_cert' => $this->option('x509cert'), + 'relay_state_url' => $this->option('relayStateUrl'), + 'name_id_format' => $this->option('nameIdFormat'), + 'metadata' => ConsoleHelper::stringToArray($this->option('metadata')) + ])); + + if (!$tenant->save()) { + $this->error('Identity Provider cannot be saved.'); + return; + } + + $this->info("The tenant #$tenant->id ($tenant->uuid) was successfully updated."); + + $this->renderTenantCredentials($tenant); + + $this->output->newLine(); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + */ + protected function interact(InputInterface $input, OutputInterface $output) + { + if (! $input->getOption('entityId')) { + $input->setOption('entityId', $this->ask('Entity ID (fx. https://sts.windows.net/65b9e948-757b-4431-b140-62a2f8a3fdeb/) (optional)')); + } + + if (! $input->getOption('loginUrl')) { + $input->setOption('loginUrl', $this->ask('Login URL (fx. https://login.microsoftonline.com/65b9e948-757b-4431-b140-62a2f8a3fdeb/saml2)')); + } + + if (! $input->getOption('logoutUrl')) { + $input->setOption('logoutUrl', $this->ask('Logout URL')); + } + + if (! $input->getOption('nameIdFormat')) { + $input->setOption('nameIdFormat', $this->choice('Name ID Format', $this->nameIdFormatValues(), 'persistent')); + } + + if (! $input->getOption('relayStateUrl')) { + $input->setOption('relayStateUrl', $this->ask('Post-login redirect URL (optional)')); + } + + if (! $input->getOption('key')) { + $input->setOption('key', $this->ask('Key/name of the identity provider (optional)')); + } + + if (! $input->getOption('metadata')) { + $input->setOption('metadata', $this->ask('Custom metadata (in format "field:value,anotherfield:value") (optional)')); + } + } + + /** + * Validation rules for the user input. + * + * @return array + */ + protected function rules(): array + { + return [ + 'id' => ['required', 'int', new Exists(config('saml2.idpModel'))], + 'key' => ['string', new Unique(config('saml2.idpModel'), 'key')], + 'entityId' => 'string', + 'loginUrl' => 'string|url', + 'logoutUrl' => 'string|url', + 'x509cert' => 'string', + 'relayStateUrl' => 'string|url', + 'metadata' => 'string', + 'nameIdFormat' => ['string', new In($this->nameIdFormatValues())] + ]; + } + + /** + * Get the values for the name ID format rule. + * + * @return string[] + */ + protected function nameIdFormatValues(): array + { + return [ + 'persistent', + 'transient', + 'emailAddress', + 'unspecified', + 'X509SubjectName', + 'WindowsDomainQualifiedName', + 'kerberos', + 'entity' + ]; + } +} diff --git a/src/Commands/UpdateTenant.php b/src/Commands/UpdateTenant.php deleted file mode 100644 index 16f104e..0000000 --- a/src/Commands/UpdateTenant.php +++ /dev/null @@ -1,90 +0,0 @@ -tenants = $tenants; - - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return void - */ - public function handle() - { - if(!$tenant = $this->tenants->findById($this->argument('id'))) { - $this->error('Cannot find a tenant #' . $this->argument('id')); - return; - } - - $tenant->update(array_filter([ - 'key' => $this->option('key'), - 'idp_entity_id' => $this->option('entityId'), - 'idp_login_url' => $this->option('loginUrl'), - 'idp_logout_url' => $this->option('logoutUrl'), - 'idp_x509_cert' => $this->option('x509cert'), - 'relay_state_url' => $this->option('relayStateUrl'), - 'name_id_format' => $this->resolveNameIdFormat(), - 'metadata' => ConsoleHelper::stringToArray($this->option('metadata')) - ])); - - if(!$tenant->save()) { - $this->error('Tenant cannot be saved.'); - return; - } - - $this->info("The tenant #{$tenant->id} ({$tenant->uuid}) was successfully updated."); - - $this->renderTenantCredentials($tenant); - - $this->output->newLine(); - } -} \ No newline at end of file diff --git a/src/Commands/ValidatesInput.php b/src/Commands/ValidatesInput.php deleted file mode 100644 index 7fe5a52..0000000 --- a/src/Commands/ValidatesInput.php +++ /dev/null @@ -1,64 +0,0 @@ -option($option) ?: 'persistent'; - - if ($this->validateNameIdFormat($value)) { - return $value; - } - - $this->error('Name ID format is invalid. Supported values: ' . implode(', ', $this->supportedNameIdFormats())); - - return null; - } - - /** - * Validate Name ID format. - * - * @param string $format - * - * @return bool - */ - protected function validateNameIdFormat(string $format): bool - { - return in_array($format, $this->supportedNameIdFormats()); - } - - /** - * The list of supported Name ID formats. - * - * See https://docs.oracle.com/cd/E19316-01/820-3886/6nfcvtepi/index.html - * - * @return string[]|array - */ - protected function supportedNameIdFormats(): array - { - return [ - 'persistent', - 'transient', - 'emailAddress', - 'unspecified', - 'X509SubjectName', - 'WindowsDomainQualifiedName', - 'kerberos', - 'entity' - ]; - } -} \ No newline at end of file diff --git a/src/Concerns/IdentityProviderAuthenticatable.php b/src/Concerns/IdentityProviderAuthenticatable.php new file mode 100644 index 0000000..c47800b --- /dev/null +++ b/src/Concerns/IdentityProviderAuthenticatable.php @@ -0,0 +1,20 @@ +morphOne(config('saml2.idpModel'), 'tenant'); + } +} diff --git a/src/Concerns/UserResolverHelpers.php b/src/Concerns/UserResolverHelpers.php new file mode 100644 index 0000000..462e967 --- /dev/null +++ b/src/Concerns/UserResolverHelpers.php @@ -0,0 +1,141 @@ +getUserId(), FILTER_VALIDATE_EMAIL)) { + return $user->getUserId(); + } + + // Otherwise, we need to lookup through attributes. + return $this->firstUserAttribute($user, $this->userEmailAttributes); + } + + /** + * Resolve a user name. + * + * @param Saml2User $user + * + * @return mixed|string|null + */ + protected function resolveUserName(Saml2User $user) + { + // First of all, we need to look up through attributes + if ($name = $this->firstUserAttribute($user, $this->userNameAttributes)) { + return $name; + } + + Log::warning('[SSO] Not able to resolve user name, extracting from email.', [ + 'samlAttributes' => $user->getAttributes(), + 'samlUserId' => $user->getUserId(), + 'samlNameId' => $user->getNameId() + ]); + + // Not the best solution, but if user name cannot be resolved, + // we can try to extract it from the email address + return $this->extractNameFromEmail( + $this->resolveUserEmail($user) + ); + } + + /** + * Find a user attribute value using a list of names. + * + * @param Saml2User $user + * @param array $attributes + * @param string|null $default + * + * @return mixed|string|null + */ + protected function firstUserAttribute(Saml2User $user, array $attributes, $default = null) + { + return Arr::first($attributes, fn($attribute) => $this->getUserAttribute($user, $attribute), $default); + } + + /** + * Get user's attribute. + * + * @param Saml2User $user + * @param string $attribute + * @param string|null $default + * + * @return string|mixed|null + */ + protected function getUserAttribute(Saml2User $user, string $attribute, string $default = null): ?string + { + foreach ($user->getAttributes() as $claim => $value) { + $value = $value[0]; + + if (strpos($claim, $attribute) !== false) { + return $value; + } + } + + return $default; + } + + /** + * Attempt to extract full name from the email address. + * + * @param string $email + * + * @return string|null + */ + protected function extractNameFromEmail(string $email): ?string + { + // Extract words from the email name + preg_match_all('/[a-z]+/i', Str::before($email, '@'), $matches); + + $words = $matches[0]; + + if (!$words) { + return null; + } + + // Keep only two first words and capitalize them + $words = array_map( + fn(string $word) => Str::title($word), + array_slice($words, 0, 2) + ); + + return implode(' ', $words); + } +} diff --git a/src/Contracts/IdentityProvidable.php b/src/Contracts/IdentityProvidable.php new file mode 100644 index 0000000..dd26aff --- /dev/null +++ b/src/Contracts/IdentityProvidable.php @@ -0,0 +1,18 @@ + [ + LoginUser::class, + ] + ]; + + /** + * Register any events for your application. + * + * @return void + */ + public function boot() + { + parent::boot(); + } +} diff --git a/src/Events/IdentityProviderResolved.php b/src/Events/IdentityProviderResolved.php new file mode 100644 index 0000000..8a4e2c2 --- /dev/null +++ b/src/Events/IdentityProviderResolved.php @@ -0,0 +1,35 @@ +auth = $auth; + $this->idp = $idp; + } +} diff --git a/src/Events/SignedIn.php b/src/Events/SignedIn.php index fef1891..6f277cd 100644 --- a/src/Events/SignedIn.php +++ b/src/Events/SignedIn.php @@ -5,11 +5,6 @@ use Slides\Saml2\Saml2User; use Slides\Saml2\Auth; -/** - * Class LoggedIn - * - * @package Slides\Saml2\Events - */ class SignedIn { /** @@ -17,14 +12,14 @@ class SignedIn * * @var Saml2User */ - public $user; + public Saml2User $user; /** * The authentication handler. * * @var Auth */ - public $auth; + public Auth $auth; /** * LoggedIn constructor. diff --git a/src/Events/SignedOut.php b/src/Events/SignedOut.php index bfb81f5..daf4b6b 100644 --- a/src/Events/SignedOut.php +++ b/src/Events/SignedOut.php @@ -2,11 +2,6 @@ namespace Slides\Saml2\Events; -/** - * Class LoggedOut - * - * @package Slides\Saml2\Events - */ class SignedOut { } diff --git a/src/Exceptions/ConfigurationException.php b/src/Exceptions/ConfigurationException.php new file mode 100644 index 0000000..552cd1a --- /dev/null +++ b/src/Exceptions/ConfigurationException.php @@ -0,0 +1,7 @@ + $message, + 'attributes' => $saml2User->getAttributes() + ]); + } + + parent::__construct($message); + } +} diff --git a/src/Facades/Auth.php b/src/Facades/Auth.php index 969f6bf..83b5412 100644 --- a/src/Facades/Auth.php +++ b/src/Facades/Auth.php @@ -7,7 +7,7 @@ /** * Class Saml2Auth * - * @method static \Slides\Saml2\Models\Tenant|null getTenant() + * @method static \Slides\Saml2\Models\IdentityProvider|null getTenant() * * @package Slides\Saml2\Facades */ @@ -22,4 +22,4 @@ protected static function getFacadeAccessor() { return 'Slides\Saml2\Auth'; } -} \ No newline at end of file +} diff --git a/src/Helpers/ConsoleHelper.php b/src/Helpers/ConsoleHelper.php index 2fe6576..4695620 100644 --- a/src/Helpers/ConsoleHelper.php +++ b/src/Helpers/ConsoleHelper.php @@ -4,11 +4,6 @@ use Illuminate\Support\Arr; -/** - * Class ConsoleHelper - * - * @package App\Helpers - */ class ConsoleHelper { /** @@ -24,7 +19,7 @@ class ConsoleHelper */ public static function stringToArray(string $string = null, string $valueDelimiter = ':', string $itemDelimiter = ',') { - if(is_null($string)) { + if (is_null($string)) { return []; } @@ -37,7 +32,7 @@ public static function stringToArray(string $string = null, string $valueDelimit $key = Arr::get($item, 0); $value = Arr::get($item, 1); - if(is_null($value)) { + if (is_null($value)) { $value = $key; $key = $index; } @@ -74,4 +69,4 @@ public static function arrayToString(array $array): string return implode(',', $values); } -} \ No newline at end of file +} diff --git a/src/Http/Controllers/Saml2Controller.php b/src/Http/Controllers/Saml2Controller.php index 61e913b..35c4388 100644 --- a/src/Http/Controllers/Saml2Controller.php +++ b/src/Http/Controllers/Saml2Controller.php @@ -2,17 +2,13 @@ namespace Slides\Saml2\Http\Controllers; +use Illuminate\Support\Facades\Log; use Slides\Saml2\Events\SignedIn; use Slides\Saml2\Auth; use Illuminate\Routing\Controller; use Illuminate\Http\Request; use OneLogin\Saml2\Error as OneLoginError; -/** - * Class Saml2Controller - * - * @package Slides\Saml2\Http\Controllers - */ class Saml2Controller extends Controller { /** @@ -47,9 +43,9 @@ public function acs(Auth $auth) { $errors = $auth->acs(); - if (!empty($errors)) { + if ($errors) { $error = $auth->getLastErrorReason(); - $uuid = $auth->getTenant()->uuid; + $uuid = $auth->getIdp()->uuid; logger()->error('saml2.error_detail', compact('uuid', 'error')); session()->flash('saml2.error_detail', [$error]); @@ -62,6 +58,15 @@ public function acs(Auth $auth) $user = $auth->getSaml2User(); + if (config('saml2.debug')) { + Log::debug('[Saml2] Received login request from a user', [ + 'idpUuid' => $user->getIdp()->idpUuid(), + 'userId' => $user->getUserId(), + 'userAttributes' => $user->getAttributes(), + 'intendedUrl' => $user->getIntendedUrl(), + ]); + } + event(new SignedIn($user, $auth)); $redirectUrl = $user->getIntendedUrl(); @@ -70,7 +75,7 @@ public function acs(Auth $auth) return redirect($redirectUrl); } - return redirect($auth->getTenant()->relay_state_url ?: config('saml2.loginRoute')); + return redirect($auth->getIdp()->relay_state_url ?: config('saml2.loginRoute')); } /** @@ -91,9 +96,9 @@ public function sls(Auth $auth) { $errors = $auth->sls(config('saml2.retrieveParametersFromServer')); - if (!empty($errors)) { + if (count($errors)) { $error = $auth->getLastErrorReason(); - $uuid = $auth->getTenant()->uuid; + $uuid = $auth->getIdp()->uuid; logger()->error('saml2.error_detail', compact('uuid', 'error')); session()->flash('saml2.error_detail', [$error]); @@ -119,7 +124,7 @@ public function sls(Auth $auth) */ public function login(Request $request, Auth $auth) { - $redirectUrl = $auth->getTenant()->relay_state_url ?: config('saml2.loginRoute'); + $redirectUrl = $auth->getIdp()->relay_state_url ?: config('saml2.loginRoute'); $auth->login($request->query('returnTo', $redirectUrl)); } diff --git a/src/Http/Middleware/ResolveIdentityProvider.php b/src/Http/Middleware/ResolveIdentityProvider.php new file mode 100644 index 0000000..4edda15 --- /dev/null +++ b/src/Http/Middleware/ResolveIdentityProvider.php @@ -0,0 +1,64 @@ +resolver = $resolver; + $this->builder = $builder; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * + * @throws NotFoundHttpException + * + * @return mixed + */ + public function handle(Request $request, \Closure $next) + { + if (!$idp = $this->resolver->resolve($request)) { + throw new IdentityProviderNotFound(); + } + + if (config('saml2.debug')) { + Log::debug('[Saml2] Identity Provider resolved', [ + 'uuid' => $idp->idpUuid() + ]); + } + + session()->flash('saml2.idp.uuid', $idp->idpUuid()); + + $this->builder->configureIdp($idp); + + return $next($request); + } +} diff --git a/src/Http/Middleware/ResolveTenant.php b/src/Http/Middleware/ResolveTenant.php deleted file mode 100644 index 9e3cc1e..0000000 --- a/src/Http/Middleware/ResolveTenant.php +++ /dev/null @@ -1,115 +0,0 @@ -tenants = $tenants; - $this->builder = $builder; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * - * @throws NotFoundHttpException - * - * @return mixed - */ - public function handle($request, \Closure $next) - { - if(!$tenant = $this->resolveTenant($request)) { - throw new NotFoundHttpException(); - } - - if (config('saml2.debug')) { - Log::debug('[Saml2] Tenant resolved', [ - 'uuid' => $tenant->uuid, - 'id' => $tenant->id, - 'key' => $tenant->key - ]); - } - - session()->flash('saml2.tenant.uuid', $tenant->uuid); - - $this->builder - ->withTenant($tenant) - ->bootstrap(); - - return $next($request); - } - - /** - * Resolve a tenant by a request. - * - * @param \Illuminate\Http\Request $request - * - * @return \Slides\Saml2\Models\Tenant|null - */ - protected function resolveTenant($request) - { - if(!$uuid = $request->route('uuid')) { - if (config('saml2.debug')) { - Log::debug('[Saml2] Tenant UUID is not present in the URL so cannot be resolved', [ - 'url' => $request->fullUrl() - ]); - } - - return null; - } - - if(!$tenant = $this->tenants->findByUUID($uuid)) { - if (config('saml2.debug')) { - Log::debug('[Saml2] Tenant doesn\'t exist', [ - 'uuid' => $uuid - ]); - } - - return null; - } - - if($tenant->trashed()) { - if (config('saml2.debug')) { - Log::debug('[Saml2] Tenant #' . $tenant->id. ' resolved but marked as deleted', [ - 'id' => $tenant->id, - 'uuid' => $uuid, - 'deleted_at' => $tenant->deleted_at->toDateTimeString() - ]); - } - - return null; - } - - return $tenant; - } -} \ No newline at end of file diff --git a/src/Http/routes.php b/src/Http/routes.php index 90a23b3..8980791 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -4,30 +4,30 @@ Route::group([ 'prefix' => config('saml2.routesPrefix'), - 'middleware' => array_merge(['saml2.resolveTenant'], config('saml2.routesMiddleware')), + 'middleware' => array_merge(['saml2.resolveIdentityProvider'], config('saml2.routesMiddleware')), ], function () { Route::get('/{uuid}/logout', array( 'as' => 'saml.logout', 'uses' => 'Slides\Saml2\Http\Controllers\Saml2Controller@logout', - )); + ))->whereUuid('uuid'); Route::get('/{uuid}/login', array( 'as' => 'saml.login', 'uses' => 'Slides\Saml2\Http\Controllers\Saml2Controller@login', - )); + ))->whereUuid('uuid'); Route::get('/{uuid}/metadata', array( 'as' => 'saml.metadata', 'uses' => 'Slides\Saml2\Http\Controllers\Saml2Controller@metadata', - )); + ))->whereUuid('uuid'); Route::post('/{uuid}/acs', array( 'as' => 'saml.acs', 'uses' => 'Slides\Saml2\Http\Controllers\Saml2Controller@acs', - )); + ))->whereUuid('uuid'); Route::get('/{uuid}/sls', array( 'as' => 'saml.sls', 'uses' => 'Slides\Saml2\Http\Controllers\Saml2Controller@sls', - )); + ))->whereUuid('uuid'); }); diff --git a/src/Listeners/LoginUser.php b/src/Listeners/LoginUser.php new file mode 100644 index 0000000..50ed34a --- /dev/null +++ b/src/Listeners/LoginUser.php @@ -0,0 +1,95 @@ +resolveOrCreateUser($event->getAuth()); + + Auth::login($user); + + tap(new Session([ + 'idp_id' => $event->getAuth()->getIdp()->id, + 'user_id' => $user, + 'payload' => $event->getAuth()->getSaml2User()->getAttributes() + ]))->save(); + } + + /** + * Resolve or create a new user. + * + * @param SamlAuth $samlAuth + * + * @return Authenticatable + */ + protected function resolveOrCreateUser(SamlAuth $samlAuth): Authenticatable + { + $resolvedUser = app(ResolvesUser::class)->resolve($samlAuth); + + if ($resolvedUser) { + return $resolvedUser; + } + + if (!config('saml2.auth.createUser')) { + throw new UserResolutionException('Cannot signup a new user. Enable this ability or create your own logic', $samlAuth->getSaml2User()); + } + + if (config('saml2.debug')) { + Log::debug('[Saml2] Creating a new user', [ + 'idpUuid' => $samlAuth->getIdp()->idpUuid(), + 'userId' => $samlAuth->getSaml2User()->getUserId(), + 'userAttributes' => $samlAuth->getSaml2User()->getAttributes(), + ]); + } + + return $this->createUser($samlAuth->getSaml2User()); + } + + /** + * Create a new user. + * + * @param Saml2User $samlUser + * + * @return \Illuminate\Config\Repository|\Illuminate\Contracts\Foundation\Application|mixed + */ + protected function createUser(Saml2User $samlUser) + { + $model = config('saml2.auth.userModel'); + + $user = new $model; + $user->name = $this->resolveUserName($samlUser); + $user->email = $this->resolveUserEmail($samlUser); + $user->password = Hash::make(Str::random()); + $user->save(); + + return $user; + } +} diff --git a/src/Models/IdentityProvider.php b/src/Models/IdentityProvider.php new file mode 100644 index 0000000..f593790 --- /dev/null +++ b/src/Models/IdentityProvider.php @@ -0,0 +1,137 @@ + 'array' + ]; + + /** + * @return string + */ + public function idpUuid(): string + { + return $this->uuid; + } + + /** + * @return string + */ + public function idpEntityId(): string + { + return $this->idp_entity_id; + } + + /** + * @return string + */ + public function idpLoginUrl(): string + { + return $this->idp_login_url; + } + + /** + * @return string + */ + public function idpLogoutUrl(): string + { + return $this->idp_logout_url; + } + + /** + * @return string + */ + public function idpX509cert(): ?string + { + return $this->idp_x509_cert; + } + + /** + * @return string + */ + public function idpNameIdFormat(): string + { + return $this->name_id_format; + } + + /** + * The tenant model. + * + * @return MorphTo + */ + public function tenant(): MorphTo + { + return $this->morphTo(); + } + + /** + * The sessions of the tenant. + * + * @return HasMany + */ + public function sessions(): HasMany + { + return $this->hasMany(Session::class); + } +} diff --git a/src/Models/Session.php b/src/Models/Session.php new file mode 100644 index 0000000..ee250de --- /dev/null +++ b/src/Models/Session.php @@ -0,0 +1,67 @@ + 'array' + ]; + + /** + * The user model. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(config('saml2.auth.userModel')); + } + + /** + * The tenant model (identity provider). + * + * @return HasOne + */ + public function tenant(): HasOne + { + return $this->hasOne(config('saml2.idpModel'), 'id', 'idp_id'); + } +} diff --git a/src/Models/Tenant.php b/src/Models/Tenant.php deleted file mode 100644 index 34ba793..0000000 --- a/src/Models/Tenant.php +++ /dev/null @@ -1,63 +0,0 @@ - 'array' - ]; -} diff --git a/src/OneLoginBuilder.php b/src/OneLoginBuilder.php index 4e1adf5..e1e9e8d 100644 --- a/src/OneLoginBuilder.php +++ b/src/OneLoginBuilder.php @@ -6,7 +6,8 @@ use OneLogin\Saml2\Utils as OneLoginUtils; use Illuminate\Support\Facades\URL; use Illuminate\Contracts\Container\Container; -use Slides\Saml2\Models\Tenant; +use Slides\Saml2\Contracts\IdentityProvidable; +use Slides\Saml2\Contracts\ResolvesIdpConfig; use Illuminate\Support\Arr; /** @@ -22,83 +23,64 @@ class OneLoginBuilder protected $app; /** - * The resolved tenant. + * The config resolver. * - * @var Tenant + * @var ResolvesIdpConfig */ - protected $tenant; + protected $configResolver; /** * OneLoginBuilder constructor. * * @param Container $app + * @param ResolvesIdpConfig $configResolver */ - public function __construct(Container $app) + public function __construct(Container $app, ResolvesIdpConfig $configResolver) { $this->app = $app; + $this->configResolver = $configResolver; } /** - * Set a tenant. + * Adjust OneLogin configuration according to the given identity provider. * - * @param Tenant $tenant - * - * @return $this - */ - public function withTenant(Tenant $tenant) - { - $this->tenant = $tenant; - - return $this; - } - - /** - * Bootstrap the OneLogin toolkit. - * - * @param Tenant $tenant + * @param IdentityProvidable $idp * * @return void */ - public function bootstrap() + public function configureIdp(IdentityProvidable $idp) { if ($this->app['config']->get('saml2.proxyVars', false)) { OneLoginUtils::setProxyVars(true); } - $this->app->singleton('OneLogin_Saml2_Auth', function ($app) { + $this->app->singleton(OneLoginAuth::class, function ($app) use ($idp) { $config = $app['config']['saml2']; - $this->setConfigDefaultValues($config); - - $oneLoginConfig = $config; - $oneLoginConfig['idp'] = [ - 'entityId' => $this->tenant->idp_entity_id, - 'singleSignOnService' => ['url' => $this->tenant->idp_login_url], - 'singleLogoutService' => ['url' => $this->tenant->idp_logout_url], - 'x509cert' => $this->tenant->idp_x509_cert - ]; - - $oneLoginConfig['sp']['NameIDFormat'] = $this->resolveNameIdFormatPrefix($this->tenant->name_id_format); + $this->setConfigDefaultValues($idp->idpUuid(), $config); - return new OneLoginAuth($oneLoginConfig); + return new OneLoginAuth( + $this->configResolver->resolve($idp, $config) + ); }); - $this->app->singleton('Slides\Saml2\Auth', function ($app) { - return new \Slides\Saml2\Auth($app['OneLogin_Saml2_Auth'], $this->tenant); + $this->app->singleton(Auth::class, function ($app) use ($idp) { + return new \Slides\Saml2\Auth($app[OneLoginAuth::class], $idp); }); } /** * Set default config values if they weren't set. * + * @param string $uuid * @param array $config * * @return void */ - protected function setConfigDefaultValues(array &$config) + protected function setConfigDefaultValues(string $uuid, array &$config): void { - foreach ($this->configDefaultValues() as $key => $default) { - if(!Arr::get($config, $key)) { + foreach ($this->configDefaultValues($uuid) as $key => $default) { + if (!Arr::get($config, $key)) { Arr::set($config, $key, $default); } } @@ -107,34 +89,16 @@ protected function setConfigDefaultValues(array &$config) /** * Configuration default values that must be replaced with custom ones. * + * @param string $uuid + * * @return array */ - protected function configDefaultValues() + protected function configDefaultValues(string $uuid): array { return [ - 'sp.entityId' => URL::route('saml.metadata', ['uuid' => $this->tenant->uuid]), - 'sp.assertionConsumerService.url' => URL::route('saml.acs', ['uuid' => $this->tenant->uuid]), - 'sp.singleLogoutService.url' => URL::route('saml.sls', ['uuid' => $this->tenant->uuid]) + 'sp.entityId' => URL::route('saml.metadata', compact('uuid')), + 'sp.assertionConsumerService.url' => URL::route('saml.acs', compact('uuid')), + 'sp.singleLogoutService.url' => URL::route('saml.sls', compact('uuid')) ]; } - - /** - * Resolve the Name ID Format prefix. - * - * @param string $format - * - * @return string - */ - protected function resolveNameIdFormatPrefix(string $format): string - { - switch ($format) { - case 'emailAddress': - case 'X509SubjectName': - case 'WindowsDomainQualifiedName': - case 'unspecified': - return 'urn:oasis:names:tc:SAML:1.1:nameid-format:' . $format; - default: - return 'urn:oasis:names:tc:SAML:2.0:nameid-format:'. $format; - } - } -} \ No newline at end of file +} diff --git a/src/Repositories/TenantRepository.php b/src/Repositories/IdentityProviderRepository.php similarity index 79% rename from src/Repositories/TenantRepository.php rename to src/Repositories/IdentityProviderRepository.php index 4734e66..018b00c 100644 --- a/src/Repositories/TenantRepository.php +++ b/src/Repositories/IdentityProviderRepository.php @@ -2,14 +2,9 @@ namespace Slides\Saml2\Repositories; -use Slides\Saml2\Models\Tenant; +use Slides\Saml2\Models\IdentityProvider; -/** - * Class TenantRepository - * - * @package Slides\Saml2\Repositories - */ -class TenantRepository +class IdentityProviderRepository { /** * Create a new query. @@ -20,10 +15,10 @@ class TenantRepository */ public function query(bool $withTrashed = false) { - $class = config('saml2.tenantModel', Tenant::class); + $class = config('saml2.idpModel', IdentityProvider::class); $query = $class::query(); - if($withTrashed) { + if ($withTrashed) { $query->withTrashed(); } @@ -35,7 +30,7 @@ public function query(bool $withTrashed = false) * * @param bool $withTrashed Whether need to include safely deleted records. * - * @return Tenant[]|\Illuminate\Database\Eloquent\Collection + * @return IdentityProvider[]|\Illuminate\Database\Eloquent\Collection */ public function all(bool $withTrashed = true) { @@ -48,7 +43,7 @@ public function all(bool $withTrashed = true) * @param int|string $key ID, key or UUID * @param bool $withTrashed Whether need to include safely deleted records. * - * @return Tenant[]|\Illuminate\Database\Eloquent\Collection + * @return IdentityProvider[]|\Illuminate\Database\Eloquent\Collection */ public function findByAnyIdentifier($key, bool $withTrashed = true) { @@ -69,7 +64,7 @@ public function findByAnyIdentifier($key, bool $withTrashed = true) * @param string $key * @param bool $withTrashed * - * @return Tenant|\Illuminate\Database\Eloquent\Model|null + * @return IdentityProvider|\Illuminate\Database\Eloquent\Model|null */ public function findByKey(string $key, bool $withTrashed = true) { @@ -84,7 +79,7 @@ public function findByKey(string $key, bool $withTrashed = true) * @param int $id * @param bool $withTrashed * - * @return Tenant|\Illuminate\Database\Eloquent\Model|null + * @return IdentityProvider|\Illuminate\Database\Eloquent\Model|null */ public function findById(int $id, bool $withTrashed = true) { @@ -99,7 +94,7 @@ public function findById(int $id, bool $withTrashed = true) * @param int $uuid * @param bool $withTrashed * - * @return Tenant|\Illuminate\Database\Eloquent\Model|null + * @return IdentityProvider|\Illuminate\Database\Eloquent\Model|null */ public function findByUUID(string $uuid, bool $withTrashed = true) { @@ -107,4 +102,4 @@ public function findByUUID(string $uuid, bool $withTrashed = true) ->where('uuid', $uuid) ->first(); } -} \ No newline at end of file +} diff --git a/src/Resolvers/ConfigResolver.php b/src/Resolvers/ConfigResolver.php new file mode 100644 index 0000000..e436860 --- /dev/null +++ b/src/Resolvers/ConfigResolver.php @@ -0,0 +1,56 @@ +idpX509cert() === null) { + throw new ConfigurationException('Identity Provider certificate is missing'); + } + + $config['idp'] = [ + 'entityId' => $idp->idpEntityId(), + 'singleSignOnService' => ['url' => $idp->idpLoginUrl()], + 'singleLogoutService' => ['url' => $idp->idpLogoutUrl()], + 'x509cert' => $idp->idpX509cert() + ]; + + $config['sp']['NameIDFormat'] = $this->resolveNameIdFormatPrefix($idp->idpNameIdFormat()); + + return $config; + } + + /** + * Resolve the Name ID Format prefix. + * + * @param string $format + * + * @return string + */ + protected function resolveNameIdFormatPrefix(string $format): string + { + switch ($format) { + case 'emailAddress': + case 'X509SubjectName': + case 'WindowsDomainQualifiedName': + case 'unspecified': + return 'urn:oasis:names:tc:SAML:1.1:nameid-format:' . $format; + default: + return 'urn:oasis:names:tc:SAML:2.0:nameid-format:'. $format; + } + } +} diff --git a/src/Resolvers/IdentityProviderResolver.php b/src/Resolvers/IdentityProviderResolver.php new file mode 100644 index 0000000..c663c43 --- /dev/null +++ b/src/Resolvers/IdentityProviderResolver.php @@ -0,0 +1,67 @@ +repository = $repository; + } + + /** + * Resolve a tenant from the request. + * + * @param Request $request + * + * @return IdentityProvidable|null + */ + public function resolve(Request $request): ?IdentityProvidable + { + if (!$uuid = $request->route('uuid')) { + if (config('saml2.debug')) { + Log::debug('[Saml2] Identity Provider UUID is not present in the URL so cannot be resolved', [ + 'url' => $request->fullUrl() + ]); + } + + return null; + } + + if (!$idp = $this->repository->findByUUID($uuid)) { + if (config('saml2.debug')) { + Log::debug('[Saml2] Identity Provider cannot be found', ['uuid' => $uuid]); + } + + return null; + } + + if ($idp->trashed()) { + if (config('saml2.debug')) { + Log::debug("[Saml2] Identity Provider #{$idp->id} resolved but marked as deleted", [ + 'id' => $idp->id, + 'uuid' => $uuid, + 'deleted_at' => $idp->deleted_at->toDateTimeString() + ]); + } + + return null; + } + + return $idp; + } +} diff --git a/src/Resolvers/UserResolver.php b/src/Resolvers/UserResolver.php new file mode 100644 index 0000000..1d1c6e8 --- /dev/null +++ b/src/Resolvers/UserResolver.php @@ -0,0 +1,38 @@ +resolveUserEmail($samlAuth->getSaml2User())) { + throw new UserResolutionException('Unable to resolve user email', $samlAuth->getSaml2User()); + } + + if (!$provider = config('auth.defaults.passwords')) { + throw new ConfigurationException('No default password provider configured'); + } + + // Attempt to retrieve user by email. + return Auth::createUserProvider($provider)->retrieveByCredentials(['email' => $email]); + } +} diff --git a/src/Saml2User.php b/src/Saml2User.php index ba37f1f..e92b82a 100644 --- a/src/Saml2User.php +++ b/src/Saml2User.php @@ -3,13 +3,8 @@ namespace Slides\Saml2; use OneLogin\Saml2\Auth as OneLoginAuth; -use Slides\Saml2\Models\Tenant; +use Slides\Saml2\Contracts\IdentityProvidable; -/** - * Class Saml2User - * - * @package Slides\Saml2 - */ class Saml2User { /** @@ -22,20 +17,20 @@ class Saml2User /** * The tenant user belongs to. * - * @var Tenant + * @var IdentityProvidable */ - protected $tenant; + protected $idp; /** * Saml2User constructor. * * @param OneLoginAuth $auth - * @param Tenant $tenant + * @param IdentityProvidable $idp */ - public function __construct(OneLoginAuth $auth, Tenant $tenant) + public function __construct(OneLoginAuth $auth, IdentityProvidable $idp) { $this->auth = $auth; - $this->tenant = $tenant; + $this->idp = $idp; } /** @@ -69,7 +64,7 @@ public function getAttribute($name) { return $this->auth->getAttribute($name); } - + /** * The attributes retrieved from assertion processed this request. * @@ -118,11 +113,11 @@ public function getIntendedUrl() */ public function parseUserAttribute($samlAttribute = null, $propertyName = null) { - if(empty($samlAttribute)) { + if (empty($samlAttribute)) { return null; } - if(empty($propertyName)) { + if (empty($propertyName)) { return $this->getAttribute($samlAttribute); } @@ -138,7 +133,7 @@ public function parseUserAttribute($samlAttribute = null, $propertyName = null) */ public function parseAttributes($attributes = []) { - foreach($attributes as $propertyName => $samlAttribute) { + foreach ($attributes as $propertyName => $samlAttribute) { $this->parseUserAttribute($samlAttribute, $propertyName); } } @@ -148,7 +143,7 @@ public function parseAttributes($attributes = []) * * @return null|string */ - public function getSessionIndex() + public function getSessionIndex(): ?string { return $this->auth->getSessionIndex(); } @@ -158,7 +153,7 @@ public function getSessionIndex() * * @return string */ - public function getNameId() + public function getNameId(): string { return $this->auth->getNameId(); } @@ -166,22 +161,22 @@ public function getNameId() /** * Set a tenant * - * @param Tenant $tenant + * @param IdentityProvidable $idp * * @return void */ - public function setTenant(Tenant $tenant) + public function setIdp(IdentityProvidable $idp) { - $this->tenant = $tenant; + $this->idp = $idp; } /** * Get a resolved tenant. * - * @return Tenant|null + * @return IdentityProvidable|null */ - public function getTenant() + public function getIdp() { - return $this->tenant; + return $this->idp; } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 72a414f..aabe141 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,11 +2,6 @@ namespace Slides\Saml2; -/** - * Class ServiceProvider - * - * @package Slides\Saml2 - */ class ServiceProvider extends \Illuminate\Support\ServiceProvider { /** @@ -14,7 +9,27 @@ class ServiceProvider extends \Illuminate\Support\ServiceProvider * * @var bool */ - protected $defer = false; + protected bool $defer = false; + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->mergeConfigFrom(__DIR__ . '/../config/saml2.php', 'saml2'); + + $this->app->bind(\Slides\Saml2\Contracts\IdentityProvidable::class, config('saml2.tenantModel')); + $this->app->bind(\Slides\Saml2\Contracts\ResolvesIdentityProvider::class, config('saml2.resolvers.idp')); + $this->app->bind(\Slides\Saml2\Contracts\ResolvesIdpConfig::class, config('saml2.resolvers.config')); + + if (config('saml2.auth.enabled')) { + $this->app->bind(\Slides\Saml2\Contracts\ResolvesUser::class, config('saml2.auth.resolver')); + } + + $this->app->register(EventServiceProvider::class); + } /** * Bootstrap the application events. @@ -23,9 +38,9 @@ class ServiceProvider extends \Illuminate\Support\ServiceProvider */ public function boot() { + $this->bootPublishes(); $this->bootMiddleware(); $this->bootRoutes(); - $this->bootPublishes(); $this->bootCommands(); $this->loadMigrations(); } @@ -37,7 +52,7 @@ public function boot() */ protected function bootRoutes() { - if($this->app['config']['saml2.useRoutes'] == true) { + if ($this->app['config']['saml2.useRoutes']) { include __DIR__ . '/Http/routes.php'; } } @@ -49,10 +64,7 @@ protected function bootRoutes() */ protected function bootPublishes() { - $source = __DIR__ . '/../config/saml2.php'; - - $this->publishes([$source => config_path('saml2.php')]); - $this->mergeConfigFrom($source, 'saml2'); + $this->publishes([__DIR__ . '/../config/saml2.php' => config_path('saml2.php')]); } /** @@ -63,12 +75,13 @@ protected function bootPublishes() protected function bootCommands() { $this->commands([ - \Slides\Saml2\Commands\CreateTenant::class, - \Slides\Saml2\Commands\UpdateTenant::class, - \Slides\Saml2\Commands\DeleteTenant::class, - \Slides\Saml2\Commands\RestoreTenant::class, - \Slides\Saml2\Commands\ListTenants::class, - \Slides\Saml2\Commands\TenantCredentials::class + \Slides\Saml2\Commands\Create::class, + \Slides\Saml2\Commands\Update::class, + \Slides\Saml2\Commands\ListAll::class, +// \Slides\Saml2\Commands\DeleteTenant::class, +// \Slides\Saml2\Commands\RestoreTenant::class, +// \Slides\Saml2\Commands\ListTenants::class, +// \Slides\Saml2\Commands\TenantCredentials::class ]); } @@ -77,9 +90,9 @@ protected function bootCommands() * * @return void */ - protected function bootMiddleware() + protected function bootMiddleware(): void { - $this->app['router']->aliasMiddleware('saml2.resolveTenant', \Slides\Saml2\Http\Middleware\ResolveTenant::class); + $this->app['router']->aliasMiddleware('saml2.resolveIdentityProvider', \Slides\Saml2\Http\Middleware\ResolveIdentityProvider::class); } /** @@ -87,7 +100,7 @@ protected function bootMiddleware() * * @return void */ - protected function loadMigrations() + protected function loadMigrations(): void { if (config('saml2.load_migrations', true)) { $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); @@ -99,7 +112,7 @@ protected function loadMigrations() * * @return array */ - public function provides() + public function provides(): array { return []; } diff --git a/src/helpers.php b/src/helpers.php index 28ec6d7..1d382e4 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -16,8 +16,8 @@ function saml_url(string $path, string $uuid = null, $parameters = [], bool $sec { $target = \Illuminate\Support\Facades\URL::to($path, $parameters, $secure); - if(!$uuid) { - if(!$uuid = saml_tenant_uuid()) { + if (!$uuid) { + if (!$uuid = saml_idp_uuid()) { return $target; } } @@ -41,8 +41,8 @@ function saml_route(string $name, string $uuid = null, $parameters = []) { $target = \Illuminate\Support\Facades\URL::route($name, $parameters, true); - if(!$uuid) { - if(!$uuid = saml_tenant_uuid()) { + if (!$uuid) { + if (!$uuid = saml_idp_uuid()) { return $target; } } @@ -51,15 +51,15 @@ function saml_route(string $name, string $uuid = null, $parameters = []) } } -if (!function_exists('saml_tenant_uuid')) +if (!function_exists('saml_idp_uuid')) { /** - * Get a resolved Tenant UUID based on current URL. + * Get a resolved Identity Provider UUID based on current URL. * * @return string|null */ - function saml_tenant_uuid() + function saml_idp_uuid() { - return session()->get('saml2.tenant.uuid'); + return session()->get('saml2.idp.uuid'); } -} \ No newline at end of file +} diff --git a/tests/Saml2AuthTest.php b/tests/Saml2AuthTest.php index f76bc1a..554e95b 100644 --- a/tests/Saml2AuthTest.php +++ b/tests/Saml2AuthTest.php @@ -190,11 +190,10 @@ protected function mockAuth() /** * Create a fake tenant. * - * @return \Slides\Saml2\Models\Tenant + * @return \Slides\Saml2\Models\IdentityProvider */ protected function mockTenant() { - return new \Slides\Saml2\Models\Tenant(); + return new \Slides\Saml2\Models\IdentityProvider(); } } -