Added #542: add saml authentication

This commit is contained in:
Johnson Yi 2020-05-06 00:06:19 +10:00
parent 8ecb7969df
commit b2930d6069
15 changed files with 1276 additions and 5 deletions

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Services\LdapAd; use App\Services\LdapAd;
use App\Services\Saml;
use Com\Tecnick\Barcode\Barcode; use Com\Tecnick\Barcode\Barcode;
use Google2FA; use Google2FA;
use Illuminate\Foundation\Auth\ThrottlesLogins; use Illuminate\Foundation\Auth\ThrottlesLogins;
@ -44,26 +45,38 @@ class LoginController extends Controller
*/ */
protected $ldap; protected $ldap;
/**
* @var Saml
*/
protected $saml;
/** /**
* Create a new authentication controller instance. * Create a new authentication controller instance.
* *
* @param LdapAd $ldap * @param LdapAd $ldap
* @param Saml $saml
* *
* @return void * @return void
*/ */
public function __construct(LdapAd $ldap) public function __construct(LdapAd $ldap, Saml $saml)
{ {
parent::__construct(); parent::__construct();
$this->middleware('guest', ['except' => ['logout','postTwoFactorAuth','getTwoFactorAuth','getTwoFactorEnroll']]); $this->middleware('guest', ['except' => ['logout','postTwoFactorAuth','getTwoFactorAuth','getTwoFactorEnroll']]);
Session::put('backUrl', \URL::previous()); Session::put('backUrl', \URL::previous());
$this->ldap = $ldap; $this->ldap = $ldap;
$this->saml = $saml;
} }
function showLoginForm(Request $request) function showLoginForm(Request $request)
{ {
$this->loginViaRemoteUser($request); $this->loginViaRemoteUser($request);
$this->loginViaSaml($request);
if (Auth::check()) { 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") { if (Setting::getSettings()->login_common_disabled == "1") {
@ -73,6 +86,47 @@ class LoginController extends Controller
return view('auth.login'); return view('auth.login');
} }
/**
* Log in a user by SAML
*
* @author Johnson Yi <jyi.dev@outlook.com>
*
* @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 * Log in a user by LDAP
* *
@ -309,17 +363,40 @@ class LoginController extends Controller
*/ */
public function logout(Request $request) 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'); $request->session()->forget('2fa_authed');
Auth::logout(); Auth::logout();
$settings = Setting::getSettings(); if (!empty($sloRedirectUrl)) {
return redirect()->away($sloRedirectUrl);
}
$customLogoutUrl = $settings->login_remote_user_custom_logout_url ; $customLogoutUrl = $settings->login_remote_user_custom_logout_url ;
if ($settings->login_remote_user_enabled == '1' && $customLogoutUrl != '') { if ($settings->login_remote_user_enabled == '1' && $customLogoutUrl != '') {
return redirect()->away($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]);
} }

View file

@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Auth;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Saml;
use Log;
/**
* This controller provides the endpoint for SAML communication and metadata.
*
* @author Johnson Yi <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use enshrined\svgSanitize\Sanitizer; use enshrined\svgSanitize\Sanitizer;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Requests\ImageUploadRequest; use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\SettingsSamlRequest;
use App\Http\Requests\SetupUserRequest; use App\Http\Requests\SetupUserRequest;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
@ -1002,6 +1003,56 @@ class SettingsController extends Controller
return redirect()->back()->withInput()->withErrors($setting->getErrors()); return redirect()->back()->withInput()->withErrors($setting->getErrors());
} }
/**
* Return a form to allow a super admin to update settings.
*
* @author Johnson Yi <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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. * Show the listing of backups.
* *

View file

@ -0,0 +1,114 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use OneLogin\Saml2\IdPMetadataParser as OneLogin_Saml2_IdPMetadataParser;
use OneLogin\Saml2\Utils as OneLogin_Saml2_Utils;
/**
* This handles validating and cleaning SAML settings provided by the user.
*
* @author Johnson Yi <jyi.dev@outlook.com>
*
* @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]);
}
});
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Providers;
use App\Services\Saml;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
class SamlServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
$this->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()
{
}
}

465
app/Services/Saml.php Normal file
View file

@ -0,0 +1,465 @@
<?php
namespace App\Services;
use OneLogin\Saml2\Auth as OneLogin_Saml2_Auth;
use OneLogin\Saml2\IdPMetadataParser as OneLogin_Saml2_IdPMetadataParser;
use App\Models\Setting;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* SAML Singleton that builds the settings and loads the onelogin/php-saml library.
*
* @author Johnson Yi <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @since 5.0.0
*
* @return bool
*/
public function isEnabled()
{
return $this->_enabled;
}
/**
* Clear SAML data from session.
*
* @author Johnson Yi <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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 <jyi.dev@outlook.com>
*
* @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;
}
}

View file

@ -45,6 +45,7 @@
"maknz/slack": "^1.7", "maknz/slack": "^1.7",
"neitanod/forceutf8": "^2.0", "neitanod/forceutf8": "^2.0",
"nesbot/carbon": "^2.32", "nesbot/carbon": "^2.32",
"onelogin/php-saml": "^3.4",
"paragonie/constant_time_encoding": "^2.3", "paragonie/constant_time_encoding": "^2.3",
"patchwork/utf8": "^1.3", "patchwork/utf8": "^1.3",
"phpdocumentor/reflection-docblock": "^5.1", "phpdocumentor/reflection-docblock": "^5.1",

90
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "693916c94ecc3dc06b80b9f83d65e4cb", "content-hash": "3fe8a441e49d1299687346810b350e00",
"packages": [ "packages": [
{ {
"name": "adldap2/adldap2", "name": "adldap2/adldap2",
@ -3595,6 +3595,56 @@
], ],
"time": "2019-09-05T13:24:16+00:00" "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", "name": "opis/closure",
"version": "3.5.1", "version": "3.5.1",
@ -4904,6 +4954,44 @@
], ],
"time": "2020-02-21T04:36:14+00:00" "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", "name": "rollbar/rollbar",
"version": "v2.0.0", "version": "v2.0.0",

View file

@ -339,6 +339,7 @@ return [
*/ */
App\Providers\MacroServiceProvider::class, App\Providers\MacroServiceProvider::class,
App\Providers\LdapServiceProvider::class, App\Providers\LdapServiceProvider::class,
App\Providers\SamlServiceProvider::class,
], ],

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSamlFieldsToSettings extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('settings', function(Blueprint $table)
{
$table->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');
});
}
}

View file

@ -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.', '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_help' => 'Enable QR Codes first to set this',
'qr_text' => 'QR Code Text', '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', 'setting' => 'Setting',
'settings' => 'Settings', 'settings' => 'Settings',
'show_alerts_in_menu' => 'Show alerts in top menu', 'show_alerts_in_menu' => 'Show alerts in top menu',

View file

@ -4,6 +4,7 @@ return [
'send_password_link' => 'Send Password Reset Link', 'send_password_link' => 'Send Password Reset Link',
'email_reset_password' => 'Email Password Reset', 'email_reset_password' => 'Email Password Reset',
'reset_password' => 'Reset Password', 'reset_password' => 'Reset Password',
'saml_login' => 'Login via SAML',
'login' => 'Login', 'login' => 'Login',
'login_prompt' => 'Please Login', 'login_prompt' => 'Please Login',
'forgot_password' => 'I forgot my password', 'forgot_password' => 'I forgot my password',

View file

@ -61,6 +61,14 @@
</div> <!-- end col-md-12 --> </div> <!-- end col-md-12 -->
</div> <!-- end row --> </div> <!-- end row -->
@if ($snipeSettings->saml_enabled)
<div class="row ">
<div class="col-md-12 text-right">
<a href="{{ route('saml.login') }}">{{ trans('auth/general.saml_login') }}</a>
</div>
</div>
@endif
</div> </div>
<div class="box-footer"> <div class="box-footer">
<button class="btn btn-lg btn-primary btn-block">{{ trans('auth/general.login') }}</button> <button class="btn btn-lg btn-primary btn-block">{{ trans('auth/general.login') }}</button>

View file

@ -222,6 +222,21 @@
</div> </div>
</div> </div>
<div class="col-md-4 col-lg-3 col-sm-6 col-xl-1">
<div class="box box-default">
<div class="box-body text-center">
<h5>
<a href="{{ route('settings.saml.index') }}">
<i class="fa fa-sign-in fa-4x" aria-hidden="true"></i>
<br><br>
<span class="name">SAML</span>
</a>
</h5>
<p class="help-block">SAML settings</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3 col-sm-6 col-xl-1"> <div class="col-md-4 col-lg-3 col-sm-6 col-xl-1">
<div class="box box-default"> <div class="box box-default">
<div class="box-body text-center"> <div class="box-body text-center">

View file

@ -0,0 +1,177 @@
@extends('layouts/default')
{{-- Page title --}}
@section('title')
Update SAML Settings
@parent
@stop
@section('header_right')
<a href="{{ route('settings.index') }}" class="btn btn-default"> {{ trans('general.back') }}</a>
@stop
{{-- Page content --}}
@section('content')
<style>
.checkbox label {
padding-right: 40px;
}
</style>
{{ Form::open(['method' => 'POST', 'files' => false, 'autocomplete' => 'false', 'class' => 'form-horizontal', 'role' => 'form']) }}
<!-- CSRF Token -->
{{csrf_field()}}
<!-- this is a hack to prevent Chrome from trying to autocomplete fields -->
<input type="text" name="prevent_autofill" id="prevent_autofill" value="" style="display:none;" />
<input type="password" name="password_fake" id="password_fake" value="" style="display:none;" />
@if (!empty($setting->saml_sp_x509cert))
{{ Form::hidden('saml_sp_x509cert', $setting->saml_sp_x509cert) }}
@endif
<div class="row">
<div class="col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2">
<div class="panel box box-default">
<div class="box-header with-border">
<h2 class="box-title">
<i class="fa fa-sign-in"></i> SAML
</h4>
</div>
<div class="box-body">
<div class="col-md-11 col-md-offset-1">
<!-- Enable SAML -->
<div class="form-group {{ $errors->has('saml_integration') ? 'error' : '' }}">
<div class="col-md-3">
{{ Form::label('saml_integration', trans('admin/settings/general.saml_integration')) }}
</div>
<div class="col-md-9">
{{ 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)
<p class="help-block"><a href="{{ route('saml.metadata') }}" target="_blank">{{ route('saml.metadata') }}</a></p>
@endif
{!! $errors->first('saml_enabled', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div>
<!-- SAML IdP Metadata -->
<div class="form-group {{ $errors->has('saml_idp_metadata') ? 'error' : '' }}">
<div class="col-md-3">
{{ Form::label('saml_idp_metadata', trans('admin/settings/general.saml_idp_metadata')) }}
</div>
<div class="col-md-9">
{{ 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', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}<br>
<button type="button" class="btn btn-default" id="saml_idp_metadata_upload_btn">{{ trans('button.select_file') }}</button>
<input type="file" class="js-uploadFile" id="saml_idp_metadata_upload"
data-maxsize="{{ \App\Helpers\Helper::file_upload_max_size() }}"
accept="text/*" style="display:none; max-width: 90%">
<p class="help-block">{{ trans('admin/settings/general.saml_idp_metadata_help') }}</p>
</div>
</div>
<!-- SAML Attribute Mapping Username -->
<div class="form-group {{ $errors->has('saml_attr_mapping_username') ? 'error' : '' }}">
<div class="col-md-3">
{{ Form::label('saml_attr_mapping_username', trans('admin/settings/general.saml_attr_mapping_username')) }}
</div>
<div class="col-md-9">
{{ Form::text('saml_attr_mapping_username', Request::old('saml_attr_mapping_username', $setting->saml_attr_mapping_username), ['class' => 'form-control','placeholder' => '', $setting->demoMode]) }}
<p class="help-block">{{ trans('admin/settings/general.saml_attr_mapping_username_help') }}</p>
{!! $errors->first('saml_attr_mapping_username', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div><!-- AD Domain -->
<!-- SAML Force Login -->
<div class="form-group">
<div class="col-md-3">
{{ Form::label('saml_forcelogin', trans('admin/settings/general.saml_forcelogin_label')) }}
</div>
<div class="col-md-9">
{{ Form::checkbox('saml_forcelogin', '1', Request::old('saml_forcelogin', $setting->saml_forcelogin),['class' => 'minimal '. $setting->demoMode, $setting->demoMode]) }}
{{ trans('admin/settings/general.saml_forcelogin') }}
<p class="help-block">{{ trans('admin/settings/general.saml_forcelogin_help') }}</p>
<p class="help-block">{{ route('login', ['nosaml']) }}</p>
{!! $errors->first('saml_forcelogin', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div>
<!-- SAML Single Log Out -->
<div class="form-group">
<div class="col-md-3">
{{ Form::label('saml_slo', trans('admin/settings/general.saml_slo_label')) }}
</div>
<div class="col-md-9">
{{ Form::checkbox('saml_slo', '1', Request::old('saml_slo', $setting->saml_slo),['class' => 'minimal '. $setting->demoMode, $setting->demoMode]) }}
{{ trans('admin/settings/general.saml_slo') }}
<p class="help-block">{{ trans('admin/settings/general.saml_slo_help') }}</p>
{!! $errors->first('saml_slo', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div>
<!-- SAML Custom Options -->
<div class="form-group {{ $errors->has('saml_custom_settings') ? 'error' : '' }}">
<div class="col-md-3">
{{ Form::label('saml_custom_settings', trans('admin/settings/general.saml_custom_settings')) }}
</div>
<div class="col-md-9">
{{ Form::textarea('saml_custom_settings', old('saml_custom_settings', $setting->saml_custom_settings), ['class' => 'form-control','placeholder' => 'example.option=false', 'wrap' => 'off', $setting->demoMode]) }}
<p class="help-block">{{ trans('admin/settings/general.saml_custom_settings_help') }}</p>
{!! $errors->first('saml_custom_settings', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div>
</div>
</div> <!--/.box-body-->
<div class="box-footer">
<div class="text-left col-md-6">
<a class="btn btn-link text-left" href="{{ route('settings.index') }}">{{ trans('button.cancel') }}</a>
</div>
<div class="text-right col-md-6">
<button type="submit" class="btn btn-primary"><i class="fa fa-check icon-white" aria-hidden="true"></i> {{ trans('general.save') }}</button>
</div>
</div>
</div> <!-- /box -->
</div> <!-- /.col-md-8-->
</div> <!-- /.row-->
{{Form::close()}}
@stop
@push('js')
<script nonce="{{ csrf_token() }}">
$('#saml_idp_metadata_upload_btn').click(function() {
$('#saml_idp_metadata_upload').click();
});
$('#saml_idp_metadata_upload').on('change', function () {
var fr = new FileReader();
fr.onload = function(e) {
$('#saml_idp_metadata').text(e.target.result);
}
fr.readAsText(this.files[0]);
});
</script>
@endpush