diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index c8a9ed1587..b17d98029e 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\Setting; use App\Models\User; use App\Services\LdapAd; +use App\Services\Saml; use Com\Tecnick\Barcode\Barcode; use Google2FA; use Illuminate\Foundation\Auth\ThrottlesLogins; @@ -44,26 +45,38 @@ class LoginController extends Controller */ protected $ldap; + /** + * @var Saml + */ + protected $saml; + /** * Create a new authentication controller instance. * * @param LdapAd $ldap + * @param Saml $saml * * @return void */ - public function __construct(LdapAd $ldap) + public function __construct(LdapAd $ldap, Saml $saml) { parent::__construct(); $this->middleware('guest', ['except' => ['logout','postTwoFactorAuth','getTwoFactorAuth','getTwoFactorEnroll']]); Session::put('backUrl', \URL::previous()); $this->ldap = $ldap; + $this->saml = $saml; } function showLoginForm(Request $request) { $this->loginViaRemoteUser($request); + $this->loginViaSaml($request); if (Auth::check()) { - return redirect()->intended('dashboard'); + return redirect()->intended('/'); + } + + if ($this->saml->isEnabled() && Setting::getSettings()->saml_forcelogin == "1" && !($request->has('nosaml') || $request->session()->has('error'))) { + return redirect()->route('saml.login'); } if (Setting::getSettings()->login_common_disabled == "1") { @@ -73,6 +86,47 @@ class LoginController extends Controller return view('auth.login'); } + /** + * Log in a user by SAML + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param Request $request + * + * @return User + * + * @throws \Exception + */ + private function loginViaSaml(Request $request) + { + $saml = $this->saml; + $samlData = $request->session()->get('saml_login'); + if ($saml->isEnabled() && !empty($samlData)) { + try { + LOG::debug("Attempting to log user in by SAML authentication."); + $user = $saml->samlLogin($samlData); + if(!is_null($user)) { + Auth::login($user, true); + } else { + $username = $saml->getUsername(); + LOG::debug("SAML user '$username' could not be found in database."); + $request->session()->flash('error', trans('auth/message.signin.error')); + $saml->clearData(); + } + + if ($user = Auth::user()) { + $user->last_login = \Carbon::now(); + $user->save(); + } + } catch (\Exception $e) { + LOG::debug("There was an error authenticating the SAML user: " . $e->getMessage()); + throw new \Exception($e->getMessage()); + } + } + } + /** * Log in a user by LDAP * @@ -309,17 +363,40 @@ class LoginController extends Controller */ public function logout(Request $request) { + $settings = Setting::getSettings(); + $saml = $this->saml; + $sloRedirectUrl = null; + $sloRequestUrl = null; + + if ($saml->isEnabled()) { + $auth = $saml->getAuth(); + $sloRedirectUrl = $request->session()->get('saml_slo_redirect_url'); + + if (!empty($auth->getSLOurl()) && $settings->saml_slo == '1' && $saml->isAuthenticated() && empty($sloRedirectUrl)) { + $sloRequestUrl = $auth->logout(null, array(), $saml->getNameId(), $saml->getSessionIndex(), true, $saml->getNameIdFormat(), $saml->getNameIdNameQualifier(), $saml->getNameIdSPNameQualifier()); + } + + $saml->clearData(); + } + + if (!empty($sloRequestUrl)) { + return redirect()->away($sloRequestUrl); + } + $request->session()->forget('2fa_authed'); Auth::logout(); - $settings = Setting::getSettings(); + if (!empty($sloRedirectUrl)) { + return redirect()->away($sloRedirectUrl); + } + $customLogoutUrl = $settings->login_remote_user_custom_logout_url ; if ($settings->login_remote_user_enabled == '1' && $customLogoutUrl != '') { return redirect()->away($customLogoutUrl); } - return redirect()->route('login')->with('success', trans('auth/message.logout.success')); + return redirect()->route('login')->with(['success' => trans('auth/message.logout.success'), 'loggedout' => true]); } diff --git a/app/Http/Controllers/Auth/SamlController.php b/app/Http/Controllers/Auth/SamlController.php new file mode 100644 index 0000000000..acf77cb762 --- /dev/null +++ b/app/Http/Controllers/Auth/SamlController.php @@ -0,0 +1,142 @@ + + * + * @since 5.0.0 + */ +class SamlController extends Controller +{ + /** + * @var Saml + */ + protected $saml; + + /** + * Create a new authentication controller instance. + * + * @return void + */ + public function __construct(Saml $saml) + { + $this->saml = $saml; + + $this->middleware('guest', ['except' => ['metadata','sls']]); + } + + /** + * Return SAML SP metadata for Snipe-IT + * + * /saml/metadata + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param Request $request + * + * @return Response + */ + public function metadata(Request $request) + { + $auth = $this->saml->getAuth(); + $settings = $auth->getSettings(); + $metadata = $settings->getSPMetadata(true); + + if (is_null($metadata)) { + return response($metadata, 403); + } + + return response($metadata)->header('Content-Type', 'text/xml'); + } + + /** + * Begin the SP-Initiated SSO by sending AuthN to the IdP. + * + * /login/saml + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param Request $request + * + * @return Redirect + */ + public function login(Request $request) + { + $auth = $this->saml->getAuth(); + $ssoUrl = $auth->login(null, array(), false, false, false, false); + return redirect()->away($ssoUrl); + } + + /** + * Receives, parses the assertion from IdP and flashes SAML data + * back to the LoginController for authentication. + * + * /saml/acs + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param Request $request + * + * @return Redirect + */ + public function acs(Request $request) + { + $saml = $this->saml; + $auth = $saml->getAuth(); + $auth->processResponse(); + $errors = $auth->getErrors(); + + if (!empty($errors)) { + Log::debug("There was an error with SAML ACS: " . implode(', ', $errors)); + Log::debug("Reason: " . $auth->getLastErrorReason()); + return redirect()->route('login')->with('error', trans('auth/message.signin.error')); + } + + $samlData = $saml->extractData(); + + return redirect()->route('login')->with('saml_login', $samlData); + } + + /** + * Receives LogoutRequest/LogoutResponse from IdP and flashes + * back to the LoginController for logging out. + * + * /saml/slo + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param Request $request + * + * @return Redirect + */ + public function sls(Request $request) + { + $auth = $this->saml->getAuth(); + $sloUrl = $auth->processSLO(true, null, null, null, true); + $errors = $auth->getErrors(); + + if (!empty($errors)) { + Log::debug("There was an error with SAML SLS: " . implode(', ', $errors)); + Log::debug("Reason: " . $auth->getLastErrorReason()); + return view('errors.403'); + } + + return redirect()->route('logout')->with('saml_slo_redirect_url', $sloUrl); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index fbfec2ce2c..7bee862a2b 100755 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use enshrined\svgSanitize\Sanitizer; use App\Helpers\Helper; use App\Http\Requests\ImageUploadRequest; +use App\Http\Requests\SettingsSamlRequest; use App\Http\Requests\SetupUserRequest; use App\Models\Setting; use App\Models\User; @@ -1002,6 +1003,56 @@ class SettingsController extends Controller return redirect()->back()->withInput()->withErrors($setting->getErrors()); } + /** + * Return a form to allow a super admin to update settings. + * + * @author Johnson Yi + * + * @since v5.0.0 + * + * @return View + */ + public function getSamlSettings() + { + $setting = Setting::getSettings(); + + return view('settings.saml', compact('setting')); + } + + /** + * Saves settings from form. + * + * @author Johnson Yi + * + * @since v5.0.0 + * + * @return View + */ + public function postSamlSettings(SettingsSamlRequest $request) + { + if (is_null($setting = Setting::getSettings())) { + return redirect()->to('admin')->with('error', trans('admin/settings/message.update.error')); + } + + $setting->saml_enabled = $request->input('saml_enabled', '0'); + $setting->saml_idp_metadata = $request->input('saml_idp_metadata'); + $setting->saml_attr_mapping_username = $request->input('saml_attr_mapping_username'); + $setting->saml_forcelogin = $request->input('saml_forcelogin', '0'); + $setting->saml_slo = $request->input('saml_slo', '0'); + if (!empty($request->input('saml_sp_privatekey'))) { + $setting->saml_sp_x509cert = $request->input('saml_sp_x509cert'); + $setting->saml_sp_privatekey = $request->input('saml_sp_privatekey'); + } + $setting->saml_custom_settings = $request->input('saml_custom_settings'); + + if ($setting->save()) { + return redirect()->route('settings.saml.index') + ->with('success', trans('admin/settings/message.update.success')); + } + + return redirect()->back()->withInput()->withErrors($setting->getErrors()); + } + /** * Show the listing of backups. * diff --git a/app/Http/Requests/SettingsSamlRequest.php b/app/Http/Requests/SettingsSamlRequest.php new file mode 100644 index 0000000000..930d6abec2 --- /dev/null +++ b/app/Http/Requests/SettingsSamlRequest.php @@ -0,0 +1,114 @@ + + * + * @since 5.0.0 + */ +class SettingsSamlRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + * + * @return bool + */ + public function authorize() + { + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + "saml_idp_metadata" => 'sometimes|required_if:saml_enabled,1', + ]; + } + + public function withValidator($validator) + { + $validator->after(function ($validator) { + if ($this->input('saml_enabled') == '1') { + if ($this->has('saml_idp_metadata')) { + $idpMetadata = $this->input('saml_idp_metadata'); + try { + if (filter_var($idpMetadata, FILTER_VALIDATE_URL)) { + $url = $idpMetadata; + $metadataInfo = OneLogin_Saml2_IdPMetadataParser::parseRemoteXML($idpMetadata); + } else { + $metadataInfo = OneLogin_Saml2_IdPMetadataParser::parseXML($idpMetadata); + } + } catch (\Exception $e) { + $validator->errors()->add('saml_idp_metadata', trans('validation.url', ['attribute' => 'Metadata'])); + } + } + } + + if ($this->input('saml_sp_regenerate_keypair') == '1' || !$this->has('saml_sp_x509cert')) { + $dn = [ + "countryName" => "US", + "stateOrProvinceName" => "N/A", + "localityName" => "N/A", + "organizationName" => "Snipe-IT", + "commonName" => "Snipe-IT", + ]; + + $pkey = openssl_pkey_new([ + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ]); + + $csr = openssl_csr_new($dn, $pkey, ['digest_alg' => 'sha256']); + + $x509 = openssl_csr_sign($csr, null, $pkey, 3650, ['digest_alg' => 'sha256']); + + openssl_x509_export($x509, $x509cert); + openssl_pkey_export($pkey, $privateKey); + + $errors = []; + while (($error = openssl_error_string() !== false)) { + $errors[] = $error; + } + + if (!(empty($x509cert) && empty($privateKey))) { + $this->merge([ + 'saml_sp_x509cert' => $x509cert, + 'saml_sp_privatekey' => $privateKey, + ]); + } + } + + if (!empty($this->input('saml_custom_settings'))) { + $req_custom_settings = preg_split('/\r\n|\r|\n/', $this->input('saml_custom_settings')); + $custom_settings = []; + + foreach ($req_custom_settings as $custom_setting) { + $split = explode('=', $custom_setting, 2); + + if (count($split) == 2) { + $split[0] = trim($split[0]); + $split[1] = trim($split[1]); + + if (!empty($split[0])) { + $custom_settings[] = implode('=', $split); + } + } + } + + $this->merge(['saml_custom_settings' => implode(PHP_EOL, $custom_settings) . PHP_EOL]); + } + }); + } +} diff --git a/app/Providers/SamlServiceProvider.php b/app/Providers/SamlServiceProvider.php new file mode 100644 index 0000000000..edf40dd3d8 --- /dev/null +++ b/app/Providers/SamlServiceProvider.php @@ -0,0 +1,69 @@ +app->singleton(Saml::class, Saml::class); + + Route::group(['namespace'=> 'App\Http\Controllers'], function () { + Route::group(['prefix'=> 'saml'], function () { + Route::get( + 'metadata', + [ + 'as' => 'saml.metadata', + 'uses' => 'Auth\SamlController@metadata' ] + ); + + Route::match( + ['get', 'post'], + 'acs', + [ + 'as' => 'saml.acs', + 'uses' => 'Auth\SamlController@acs' ] + ); + + Route::get( + 'sls', + [ + 'as' => 'saml.sls', + 'uses' => 'Auth\SamlController@sls' ] + ); + }); + + Route::get( + 'login/saml', + [ + 'as' => 'saml.login', + 'uses' => 'Auth\SamlController@login' ] + ); + + Route::group(['prefix' => 'admin','middleware' => ['auth', 'authorize:superuser']], function () { + + Route::get('saml', ['as' => 'settings.saml.index','uses' => 'SettingsController@getSamlSettings' ]); + Route::post('saml', ['as' => 'settings.saml.save','uses' => 'SettingsController@postSamlSettings' ]); + + }); + }); + } + + /** + * Register the application services. + * + * @return void + */ + public function register() + { + } +} diff --git a/app/Services/Saml.php b/app/Services/Saml.php new file mode 100644 index 0000000000..7a834c303e --- /dev/null +++ b/app/Services/Saml.php @@ -0,0 +1,465 @@ + + * + * @since 5.0.0 + */ +class Saml +{ + const DATA_SESSION_KEY = '_samlData'; + + /** + * OneLogin_Saml2_Auth instance. + * + * @var OneLogin\Saml2\Auth + */ + private $_auth; + + /** + * if SAML is enabled and has valid settings. + * + * @var bool + */ + private $_enabled = false; + + /** + * Settings to be passed to OneLogin_Saml2_Auth. + * + * @var array + */ + private $_settings = []; + + /** + * User attributes data. + * + * @var array + */ + private $_attributes = []; + + /** + * User attributes data with FriendlyName index. + * + * @var array + */ + private $_attributesWithFriendlyName = []; + + /** + * NameID + * + * @var string + */ + private $_nameid; + + /** + * NameID Format + * + * @var string + */ + private $_nameidFormat; + + /** + * NameID NameQualifier + * + * @var string + */ + private $_nameidNameQualifier; + + /** + * NameID SP NameQualifier + * + * @var string + */ + private $_nameidSPNameQualifier; + + /** + * If user is authenticated. + * + * @var bool + */ + private $_authenticated = false; + + /** + * SessionIndex. When the user is logged, this stored it + * from the AuthnStatement of the SAML Response + * + * @var string + */ + private $_sessionIndex; + + /** + * SessionNotOnOrAfter. When the user is logged, this stored it + * from the AuthnStatement of the SAML Response + * + * @var int|null + */ + private $_sessionExpiration; + + /** + * Initializes the SAML service and builds the OneLogin_Saml2_Auth instance. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @throws Exception + * @throws Error + */ + function __construct() + { + $this->loadSettings(); + + if ($this->isEnabled()) { + $this->loadDataFromSession(); + } else { + $this->clearData(); + } + + try { + $this->_auth = new OneLogin_Saml2_Auth($this->_settings); + } catch (Exception $e) { + if ($this->isEnabled()) { + throw $e; + } + + $this->_enabled = false; + } + } + + /** + * Builds settings from Snipe-IT for OneLogin_Saml2_Auth. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return void + */ + private function loadSettings() + { + $setting = Setting::getSettings(); + $settings = []; + + $this->_enabled = $setting->saml_enabled == '1'; + + if ($this->isEnabled()) { + data_set($settings, 'sp.entityId', url('/')); + data_set($settings, 'sp.assertionConsumerService.url', route('saml.acs')); + data_set($settings, 'sp.singleLogoutService.url', route('saml.sls')); + data_set($settings, 'sp.x509cert', $setting->saml_sp_x509cert); + data_set($settings, 'sp.privateKey', $setting->saml_sp_privatekey); + data_set($settings, 'security.wantAssertionsSigned', true); + data_set($settings, 'security.requestedAuthnContext', false); + + if (!empty(data_get($settings, 'sp.privateKey'))) { + data_set($settings, 'security.logoutRequestSigned', true); + data_set($settings, 'security.logoutResponseSigned', true); + } + + $idpMetadata = $setting->saml_idp_metadata; + $updatedAt = $setting->updated_at->timestamp; + $metadataCache = Cache::get('saml_idp_metadata_cache'); + try { + $url = null; + $metadataInfo = null; + + if (empty($metadataCache) || $metadataCache['updated_at'] != $updatedAt) { + if (filter_var($idpMetadata, FILTER_VALIDATE_URL)) { + $url = $idpMetadata; + $metadataInfo = OneLogin_Saml2_IdPMetadataParser::parseRemoteXML($idpMetadata); + } else { + $metadataInfo = OneLogin_Saml2_IdPMetadataParser::parseXML($idpMetadata); + } + + Cache::put('saml_idp_metadata_cache', [ + 'updated_at' => $updatedAt, + 'url' => $url, + 'metadata_info' => $metadataInfo, + ], 604800); + } else { + $metadataInfo = $metadataCache['metadata_info']; + } + + $settings = OneLogin_Saml2_IdPMetadataParser::injectIntoSettings($settings, $metadataInfo); + } catch (Exception $e) { + } + + $custom_settings = preg_split('/\r\n|\r|\n/', $setting->saml_custom_settings); + if ($custom_settings){ + foreach($custom_settings as $custom_setting) { + $split = explode('=', $custom_setting, 2); + + if (count($split) == 2) { + $boolValue = filter_var($split[1], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if (!is_null($boolValue)) { + $split[1] = $boolValue; + } + + data_set($settings, $split[0], $split[1]); + } + } + } + + $this->_settings = $settings; + } + } + + /** + * Load SAML data from Session. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return void + */ + private function loadDataFromSession() { + $samlData = collect(session(self::DATA_SESSION_KEY)); + $this->_authenticated = !$samlData->isEmpty(); + $this->_nameid = $samlData->get('nameId'); + $this->_nameidFormat = $samlData->get('nameIdFormat'); + $this->_nameidNameQualifier = $samlData->get('nameIdNameQualifier'); + $this->_nameidSPNameQualifier = $samlData->get('nameIdSPNameQualifier'); + $this->_sessionIndex = $samlData->get('sessionIndex'); + $this->_sessionExpiration = $samlData->get('sessionExpiration'); + $this->_attributes = $samlData->get('attributes'); + $this->_attributesWithFriendlyName = $samlData->get('attributesWithFriendlyName'); + } + + /** + * Save SAML data to Session. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param string $data + * + * @return void + */ + private function saveDataToSession($data) + { + return session([self::DATA_SESSION_KEY => $data]); + } + + /** + * Check to see if SAML is enabled and has valid settings. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return bool + */ + public function isEnabled() + { + return $this->_enabled; + } + + /** + * Clear SAML data from session. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return void + */ + public function clearData() + { + Session::forget(self::DATA_SESSION_KEY); + $this->loadDataFromSession(); + } + + /** + * Find user from SAML data. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @param string $data + * + * @return \App\Models\User + */ + public function samlLogin($data) { + $setting = Setting::getSettings(); + $this->saveDataToSession($data); + $this->loadDataFromSession(); + + $username = $this->getUsername(); + + return User::where('username', '=', $username)->whereNull('deleted_at')->where('activated', '=', '1')->first(); + } + + /** + * Returns the OneLogin_Saml2_Auth instance. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return OneLogin\Saml2\Auth + */ + public function getAuth() { + if (!$this->isEnabled()) { + throw new HttpException(403, 'SAML not enabled.'); + } + + return $this->_auth; + } + + /** + * Extract data from SAML Response. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return array + */ + public function extractData() + { + $auth = $this->getAuth(); + return [ + 'attributes' => $auth->getAttributes(), + 'attributesWithFriendlyName' => $auth->getAttributesWithFriendlyName(), + 'nameId' => $auth->getNameId(), + 'nameIdFormat' => $auth->getNameIdFormat(), + 'nameIdNameQualifier' => $auth->getNameIdNameQualifier(), + 'nameIdSPNameQualifier' => $auth->getNameIdSPNameQualifier(), + 'sessionIndex' => $auth->getSessionIndex(), + 'sessionExpiration' => $auth->getSessionExpiration(), + ]; + } + + /** + * Checks if the user is authenticated or not. + * + * @return bool True if the user is authenticated + */ + public function isAuthenticated() + { + return $this->_authenticated; + } + + /** + * Returns the set of SAML attributes. + * + * @return array Attributes of the user. + */ + public function getAttributes() + { + return $this->_attributes; + } + + /** + * Returns the set of SAML attributes indexed by FriendlyName + * + * @return array Attributes of the user. + */ + public function getAttributesWithFriendlyName() + { + return $this->_attributesWithFriendlyName; + } + + /** + * Returns the nameID + * + * @return string The nameID of the assertion + */ + public function getNameId() + { + return $this->_nameid; + } + + /** + * Returns the nameID Format + * + * @return string The nameID Format of the assertion + */ + public function getNameIdFormat() + { + return $this->_nameidFormat; + } + + /** + * Returns the nameID NameQualifier + * + * @return string The nameID NameQualifier of the assertion + */ + public function getNameIdNameQualifier() + { + return $this->_nameidNameQualifier; + } + + /** + * Returns the nameID SP NameQualifier + * + * @return string The nameID SP NameQualifier of the assertion + */ + public function getNameIdSPNameQualifier() + { + return $this->_nameidSPNameQualifier; + } + + /** + * Returns the SessionIndex + * + * @return string|null The SessionIndex of the assertion + */ + public function getSessionIndex() + { + return $this->_sessionIndex; + } + + /** + * Returns the SessionNotOnOrAfter + * + * @return int|null The SessionNotOnOrAfter of the assertion + */ + public function getSessionExpiration() + { + return $this->_sessionExpiration; + } + + /** + * Returns the correct username from SAML Response. + * + * @author Johnson Yi + * + * @since 5.0.0 + * + * @return string + */ + public function getUsername() + { + $setting = Setting::getSettings(); + $username = $this->getNameId(); + + if (!empty($setting->saml_attr_mapping_username)) + { + $attributes = $this->getAttributes(); + + if (isset($attributes[$setting->saml_attr_mapping_username])) { + $username = $attributes[$setting->saml_attr_mapping_username][0]; + } + } + + return $username; + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index ece7ee9cb5..5e0e818e24 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "maknz/slack": "^1.7", "neitanod/forceutf8": "^2.0", "nesbot/carbon": "^2.32", + "onelogin/php-saml": "^3.4", "paragonie/constant_time_encoding": "^2.3", "patchwork/utf8": "^1.3", "phpdocumentor/reflection-docblock": "^5.1", diff --git a/composer.lock b/composer.lock index 7370e7a6af..5f627c56bb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "693916c94ecc3dc06b80b9f83d65e4cb", + "content-hash": "3fe8a441e49d1299687346810b350e00", "packages": [ { "name": "adldap2/adldap2", @@ -3595,6 +3595,56 @@ ], "time": "2019-09-05T13:24:16+00:00" }, + { + "name": "onelogin/php-saml", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/onelogin/php-saml.git", + "reference": "5fbf3486704ac9835b68184023ab54862c95f213" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/onelogin/php-saml/zipball/5fbf3486704ac9835b68184023ab54862c95f213", + "reference": "5fbf3486704ac9835b68184023ab54862c95f213", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "robrichards/xmlseclibs": ">=3.0.4" + }, + "require-dev": { + "pdepend/pdepend": "^2.5.0", + "php-coveralls/php-coveralls": "^1.0.2 || ^2.0", + "phploc/phploc": "^2.1 || ^3.0 || ^4.0", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1", + "sebastian/phpcpd": "^2.0 || ^3.0 || ^4.0", + "squizlabs/php_codesniffer": "^3.1.1" + }, + "suggest": { + "ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs", + "ext-gettext": "Install gettext and php5-gettext libs to handle translations", + "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)" + }, + "type": "library", + "autoload": { + "psr-4": { + "OneLogin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "OneLogin PHP SAML Toolkit", + "homepage": "https://developers.onelogin.com/saml/php", + "keywords": [ + "SAML2", + "onelogin", + "saml" + ], + "time": "2019-11-25T17:30:07+00:00" + }, { "name": "opis/closure", "version": "3.5.1", @@ -4904,6 +4954,44 @@ ], "time": "2020-02-21T04:36:14+00:00" }, + { + "name": "robrichards/xmlseclibs", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "8d8e56ca7914440a8c60caff1a865e7dff1d9a5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/8d8e56ca7914440a8c60caff1a865e7dff1d9a5a", + "reference": "8d8e56ca7914440a8c60caff1a865e7dff1d9a5a", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "time": "2020-04-22T17:19:51+00:00" + }, { "name": "rollbar/rollbar", "version": "v2.0.0", diff --git a/config/app.php b/config/app.php index 384cb10a39..b4c21884b3 100755 --- a/config/app.php +++ b/config/app.php @@ -339,6 +339,7 @@ return [ */ App\Providers\MacroServiceProvider::class, App\Providers\LdapServiceProvider::class, + App\Providers\SamlServiceProvider::class, ], diff --git a/database/migrations/2020_04_29_222305_add_saml_fields_to_settings.php b/database/migrations/2020_04_29_222305_add_saml_fields_to_settings.php new file mode 100644 index 0000000000..90c656ce24 --- /dev/null +++ b/database/migrations/2020_04_29_222305_add_saml_fields_to_settings.php @@ -0,0 +1,48 @@ +boolean('saml_enabled')->default(0); + $table->text('saml_idp_metadata')->nullable()->default(NULL); + $table->string('saml_attr_mapping_username')->nullable()->default(NULL); + $table->boolean('saml_forcelogin')->default(0); + $table->boolean('saml_slo')->default(0); + $table->text('saml_sp_x509cert')->nullable()->default(NULL); + $table->text('saml_sp_privatekey')->nullable()->default(NULL); + $table->text('saml_custom_settings')->nullable()->default(NULL); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('settings', function(Blueprint $table) + { + $table->dropColumn('saml_enabled'); + $table->dropColumn('saml_idp_metadata'); + $table->dropColumn('saml_attr_mapping_username'); + $table->dropColumn('saml_forcelogin'); + $table->dropColumn('saml_slo'); + $table->dropColumn('saml_sp_x509cert'); + $table->dropColumn('saml_sp_privatekey'); + $table->dropColumn('saml_custom_settings'); + }); + } + +} diff --git a/resources/lang/en/admin/settings/general.php b/resources/lang/en/admin/settings/general.php index 37af7fd1ca..745c8e8da1 100644 --- a/resources/lang/en/admin/settings/general.php +++ b/resources/lang/en/admin/settings/general.php @@ -118,6 +118,20 @@ return array( 'pwd_secure_uncommon_help' => 'This will disallow users from using common passwords from the top 10,000 passwords reported in breaches.', 'qr_help' => 'Enable QR Codes first to set this', 'qr_text' => 'QR Code Text', + 'saml_enabled' => 'SAML enabled', + 'saml_integration' => 'SAML Integration', + 'saml_idp_metadata' => 'SAML IdP Metadata', + 'saml_idp_metadata_help' => 'You can specify the IdP metadata using a URL or XML file.', + 'saml_attr_mapping_username' => 'Attribute Mapping - Username', + 'saml_attr_mapping_username_help' => 'NameID will be used if attribute mapping is unspecified or invalid.', + 'saml_forcelogin_label' => 'SAML Force Login', + 'saml_forcelogin' => 'Make SAML the primary login', + 'saml_forcelogin_help' => 'You can use \'/login?nosaml\' to get to the normal login page.', + 'saml_slo_label' => 'SAML Single Log Out', + 'saml_slo' => 'Send a LogoutRequest to IdP on Logout', + 'saml_slo_help' => 'This will cause the user to be first redirected to the Idp on logout. Leave unchecked if the IdP doesn\'t correctly support SP-initiated SAML SLO.', + 'saml_custom_settings' => 'SAML Custom Settings', + 'saml_custom_settings_help' => 'You can specify additional settings to the onelogin/php-saml library. Use at your own risk.', 'setting' => 'Setting', 'settings' => 'Settings', 'show_alerts_in_menu' => 'Show alerts in top menu', diff --git a/resources/lang/en/auth/general.php b/resources/lang/en/auth/general.php index bf88cba77a..cabb5f8bec 100644 --- a/resources/lang/en/auth/general.php +++ b/resources/lang/en/auth/general.php @@ -4,6 +4,7 @@ return [ 'send_password_link' => 'Send Password Reset Link', 'email_reset_password' => 'Email Password Reset', 'reset_password' => 'Reset Password', + 'saml_login' => 'Login via SAML', 'login' => 'Login', 'login_prompt' => 'Please Login', 'forgot_password' => 'I forgot my password', diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 8870484104..b424bee501 100755 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -61,6 +61,14 @@ + + @if ($snipeSettings->saml_enabled) + + @endif +
+
+
+
+ + +

+ SAML +
+
+

SAML settings

+
+
+
+
diff --git a/resources/views/settings/saml.blade.php b/resources/views/settings/saml.blade.php new file mode 100644 index 0000000000..cd42a0143c --- /dev/null +++ b/resources/views/settings/saml.blade.php @@ -0,0 +1,177 @@ +@extends('layouts/default') + +{{-- Page title --}} +@section('title') + Update SAML Settings + @parent +@stop + +@section('header_right') + {{ trans('general.back') }} +@stop + + +{{-- Page content --}} +@section('content') + + + + + {{ Form::open(['method' => 'POST', 'files' => false, 'autocomplete' => 'false', 'class' => 'form-horizontal', 'role' => 'form']) }} + + {{csrf_field()}} + + + + + + + @if (!empty($setting->saml_sp_x509cert)) + {{ Form::hidden('saml_sp_x509cert', $setting->saml_sp_x509cert) }} + @endif + +
+
+ + +
+
+

+ SAML +

+
+
+ + +
+ + +
+
+ {{ Form::label('saml_integration', trans('admin/settings/general.saml_integration')) }} +
+
+ {{ Form::checkbox('saml_enabled', '1', Request::old('saml_enabled', $setting->saml_enabled), ['class' => 'minimal '. $setting->demoMode, $setting->demoMode]) }} + {{ trans('admin/settings/general.saml_enabled') }} + @if ($setting->saml_enabled) +

{{ route('saml.metadata') }}

+ @endif + {!! $errors->first('saml_enabled', '') !!} +
+
+ + +
+
+ {{ Form::label('saml_idp_metadata', trans('admin/settings/general.saml_idp_metadata')) }} +
+
+ {{ Form::textarea('saml_idp_metadata', old('saml_idp_metadata', $setting->saml_idp_metadata), ['class' => 'form-control','placeholder' => 'https://example.com/idp/metadata', 'wrap' => 'off', $setting->demoMode]) }} + {!! $errors->first('saml_idp_metadata', '') !!}
+ + + +

{{ trans('admin/settings/general.saml_idp_metadata_help') }}

+
+
+ + +
+
+ {{ Form::label('saml_attr_mapping_username', trans('admin/settings/general.saml_attr_mapping_username')) }} +
+
+ {{ Form::text('saml_attr_mapping_username', Request::old('saml_attr_mapping_username', $setting->saml_attr_mapping_username), ['class' => 'form-control','placeholder' => '', $setting->demoMode]) }} +

{{ trans('admin/settings/general.saml_attr_mapping_username_help') }}

+ {!! $errors->first('saml_attr_mapping_username', '') !!} +
+
+ + +
+
+ {{ Form::label('saml_forcelogin', trans('admin/settings/general.saml_forcelogin_label')) }} +
+
+ {{ Form::checkbox('saml_forcelogin', '1', Request::old('saml_forcelogin', $setting->saml_forcelogin),['class' => 'minimal '. $setting->demoMode, $setting->demoMode]) }} + {{ trans('admin/settings/general.saml_forcelogin') }} +

{{ trans('admin/settings/general.saml_forcelogin_help') }}

+

{{ route('login', ['nosaml']) }}

+ {!! $errors->first('saml_forcelogin', '') !!} +
+
+ + +
+
+ {{ Form::label('saml_slo', trans('admin/settings/general.saml_slo_label')) }} +
+
+ {{ Form::checkbox('saml_slo', '1', Request::old('saml_slo', $setting->saml_slo),['class' => 'minimal '. $setting->demoMode, $setting->demoMode]) }} + {{ trans('admin/settings/general.saml_slo') }} +

{{ trans('admin/settings/general.saml_slo_help') }}

+ {!! $errors->first('saml_slo', '') !!} +
+
+ + +
+
+ {{ Form::label('saml_custom_settings', trans('admin/settings/general.saml_custom_settings')) }} +
+
+ {{ Form::textarea('saml_custom_settings', old('saml_custom_settings', $setting->saml_custom_settings), ['class' => 'form-control','placeholder' => 'example.option=false', 'wrap' => 'off', $setting->demoMode]) }} +

{{ trans('admin/settings/general.saml_custom_settings_help') }}

+ {!! $errors->first('saml_custom_settings', '') !!} +
+
+ + +
+
+ +
+ +
+
+ + {{Form::close()}} + + +@stop + +@push('js') + +@endpush + +