saml_sp-8.x-3.x-dev/modules/saml_sp_drupal_login/saml_sp_drupal_login.module
modules/saml_sp_drupal_login/saml_sp_drupal_login.module
<?php
/**
* @file
* SAML Drupal Login.
*
* Uses the SAML Service Provider module to provide a Drupal-login
* authentication module.
*/
use Drupal\Core\Render\Element;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\saml_sp\Entity\Idp;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use OneLogin\Saml2\Response;
/*
// Used by commented code in function saml_sp_user_logout():
use OneLogin\Saml2\LogoutRequest;
use OneLogin\Saml2\Settings;
use OneLogin\Saml2\Utils;
/**/
/**
* Implements hook_form_FORM_ID_alter().
*/
function saml_sp_drupal_login_form_user_pass_alter(&$form, $form_state, $form_id) {
$config = \Drupal::config('saml_sp_drupal_login.config');
$user = \Drupal::currentUser();
if ($config->get('force_saml_only') && !$user->hasPermission('administer users')) {
// Disable the submit button to make user understand that submission is not
// available.
$form['actions']['submit']['#attributes']['disabled'] = 'disabled';
// The submit button is blocked client side, we also need to remove the
// textfield so that there is really nothing in the form.
$form['name']['#access'] = FALSE;
\Drupal::messenger()->addWarning(t('You are not allowed to log in or reset password with Drupal. Please use the SAML login.'));
}
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function saml_sp_drupal_login_form_user_form_alter(&$form, $form_state, $form_id) {
$config = \Drupal::config('saml_sp_drupal_login.config');
$user = \Drupal::currentUser();
if ($config->get('force_saml_only') && !$user->hasPermission('administer users')) {
hide($form['account']['mail']);
hide($form['account']['pass']);
hide($form['account']['current_pass_required_values']);
hide($form['account']['current_pass']);
}
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function saml_sp_drupal_login_form_user_login_form_alter(&$form, $form_state, $form_id) {
$query = \Drupal::request()->query;
$config = \Drupal::config('saml_sp_drupal_login.config');
$idps = $config->get('idp');
$enabled_idps = [];
$url_options = [];
if ($query->get('returnTo', NULL) !== NULL) {
$url_options['query']['returnTo'] = $query->get('returnTo');
}
elseif ($query->get('destination', NULL) !== NULL) {
$url_options['query']['returnTo'] = $query->get('destination');
}
if (!empty($idps)) {
foreach ($idps as $key => $value) {
if ($value) {
$enabled_idps[$key] = $key;
}
}
}
if (empty($enabled_idps)) {
// There are no enabled IdPs, so we aren't doing anything to the form.
return;
}
if ($config->get('force_saml_only')) {
// Disable caching of the login page.
\Drupal::service('page_cache_kill_switch')->trigger();
// Only SAML logins are accepted, so don't show the form.
foreach (Element::children($form) as $key) {
$form[$key]['#access'] = FALSE;
}
if (count($enabled_idps) == 1) {
// There is only one IdP so redirect to its login page to remove one step.
$redirect_url = Url::fromRoute('saml_sp_drupal_login.login', ['idp' => array_shift($enabled_idps)], $url_options);
$response = new RedirectResponse($redirect_url->toString());
$response->send();
}
}
$idps = saml_sp__load_all_idps();
$links = [];
foreach ($enabled_idps as $value) {
if (empty($idps[$value])) {
continue;
}
$links[] = Link::createFromRoute(t('Log in to @site_name using %idp.', [
'@site_name' => \Drupal::config('system.site')->get('name'),
'%idp' => $idps[$value]->label(),
]), 'saml_sp_drupal_login.login', ['idp' => $value], $url_options);
}
$form['saml_sp_drupal_login_links'] = [
'#theme' => 'item_list',
'#items' => $links,
];
$form['#cache']['tags'] = isset($form['#cache']['tags']) ? array_merge($form['#cache']['tags'], $config->getCacheTags()) : $config->getCacheTags();
}
/**
* SAML authentication callback.
*/
function saml_sp_drupal_login__saml_authenticate($is_valid, Response $saml_response, Idp $idp) {
$relay_state = \Drupal::request()->request->get('RelayState');
$redirect_url = $relay_state ?: Url::fromRoute('<front>')->toString();
if (!$is_valid) {
\Drupal::messenger()->addError(t('Could not authenticate via %idp_label', ['%idp_label' => $idp->label()]));
\Drupal::logger('saml_sp')->warning('Could not authenticate via %idp_label', ['%idp_label' => $idp->label()]);
return new RedirectResponse($redirect_url);
}
try {
$attributes = $saml_response->getAttributes();
}
catch (Exception $e) {
\Drupal::messenger()->addError(t('An error occurred when parsing the response from %idp_label', ['%idp_label' => $idp->label()]));
\Drupal::logger('saml_sp')->error('An error occurred when parsing the response from %idp_label: %exception', ['%idp_label' => $idp->label(), '%exception' => $e->__toString()]);
return new RedirectResponse($redirect_url);
}
// Get the NameID value from response.
$name_id = $saml_response->getNameId();
if (\Drupal::config('saml_sp.settings')->get('debug')) {
_saml_sp__debug('Response NameId', $name_id);
}
// Check for the presence of an email attribute in the response from
// the IdP. It may be necessary if the NameID request isn't for email,
// or if the user has more than one email address.
$emails = [];
if ($idp->getNameIdField() == 'mail') {
$emails[] = $name_id;
}
$mail_keys = ['mail', 'urn:oid:0.9.2342.19200300.100.1.3', 'email'];
foreach ($mail_keys as $key) {
if (!empty($attributes[$key]) && is_array($attributes[$key])) {
foreach ($attributes[$key] as $index => $value) {
if (!empty($value)) {
$emails[] = $value;
}
}
}
}
if (empty($emails)) {
// TODO: Either fail completely here or add tests for email in the
// cases below where it is required.
\Drupal::logger('saml_sp')->warning('No mail attribute available; please check IdP %idp_label configuration.', ['%idp_label' => $idp->label()]);
}
$site_register_access = \Drupal::config('user.settings')->get('register');
$config = \Drupal::config('saml_sp_drupal_login.config');
$success = FALSE;
$session_data = [
'idp' => $idp,
'session_index' => $saml_response->getSessionIndex(),
];
if ($user = saml_sp_drupal_login_get_user($name_id, $idp->getNameIdField(), $emails)) {
// Successful login to existing user account.
$success = TRUE;
}
elseif ($site_register_access == UserInterface::REGISTER_VISITORS || $config->get('account_request_create_account')) {
// Successful authentication, but no user account.
// New users are allowed to register, or our config bypasses the need for
// administrator approval.
$language = \Drupal::languageManager()->getCurrentLanguage()->getId();
$user = User::create();
// Mandatory:
$user->setPassword(bin2hex(random_bytes(64)));
$user->enforceIsNew();
$user->setEmail($emails[0]);
$user->setUsername($emails[0]);
// Optional:
$user->set('init', $emails[0]);
$user->set('langcode', $language);
$user->set('preferred_langcode', $language);
$user->set('preferred_admin_langcode', $language);
/*
$user->set('setting_name', 'setting_value');
$user->addRole('rid');
/**/
// Activate and save user account.
$user->activate();
$result = $user->save();
\Drupal::logger('saml_sp')->notice('New SSO user account for %mail with UID %uid.', ['%mail' => $emails[0], '%uid' => $user->id()]);
$success = TRUE;
}
elseif ($config->get('no_account_authenticated_user_role') && $config->get('no_account_authenticated_user_account')) {
// Successful authentication, but no user account.
// The setting allows for them to get an authenticated role.
$user = User::load($config->get('no_account_authenticated_user_account'));
if (empty($user)) {
\Drupal::messenger()->addError(t('You have been authenticated but there is no account available for you to continue logging in. Please contact a site administrator.'));
\Drupal::logger('saml_sp')->notice('User authenticated via %idp_label with email %mail, cannot grant access to generic account as the generic account could not be loaded.', [
'%idp_label' => $idp->label(),
'%mail' => '[' . implode(', ', $emails) . ']',
]);
$success = FALSE;
}
else {
\Drupal::logger('saml_sp')->notice('User authenticated via %idp_label with email %mail, granted access to %name account.', [
'%idp_label' => $idp->label(),
'%mail' => '[' . implode(', ', $emails) . ']',
'%name' => $user->getAccountName(),
]);
$success = TRUE;
}
}
else {
// Successful authentication, but no user account.
saml_sp_drupal_login_set_session($session_data);
$tokens = [
'%mail' => '[' . implode(', ', $emails) . ']',
'%idp_label' => $idp->label(),
];
$rvaa = ($site_register_access == UserInterface::REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL);
$arra = $config->get('account_request_request_account');
if (!$rvaa && !$arra) {
// Only administrators can register new users.
$no_account_message = t('No account matching %mail has been found. Please contact a site administrator.', $tokens);
\Drupal::messenger()->addWarning($no_account_message);
}
else {
// The user is allowed to request an account from administrators.
// Do not create an account, and redirect to the registration page.
if ($rvaa) {
// User is allowed to request by account settings.
$registration_route = 'user.register';
}
else {
// User is allowed to request by SAML SP Drupal Login settings.
$registration_route = 'saml_sp_drupal_login.register';
}
\Drupal::messenger()->addWarning(t('This site requires you to request an account.'));
$redirect_url = Url::fromRoute($registration_route, [], ['query' => ['email' => $emails[0]]])->toString();
}
\Drupal::logger('saml_sp')->warning("User attempting to log in through %idp_label with %mail which doesn't match any accounts.", $tokens);
}
if ($success) {
// @see user_login_name_validate().
if ($user->isBlocked() || !$user->isActive()) {
\Drupal::messenger()->addError(t('The username %name has not been activated or is blocked.', ['%name' => $user->getAccountName()]));
if (\Drupal::config('saml_sp.settings')->get('debug')) {
_saml_sp__debug('Account', $user);
_saml_sp__debug('Response NameId', $name_id);
}
}
else {
// TODO: this might not be the right place for this. It doesn't do
// anything right now anyway.
saml_sp_drupal_login_update_user_attributes($user, $emails, $attributes);
\Drupal::logger('saml_sp')->notice(
'User %name logging in through SAML via %idp_name. with NameID %name_id and attributes %attributes',
[
'%name' => $user->getAccountName(),
'%idp_name' => $idp->label(),
'%name_id' => $name_id,
'%attributes' => print_r($attributes, TRUE),
]);
// Store the fact that the user logged in via the SAML SP module.
saml_sp_drupal_login_set_session($session_data);
user_login_finalize($user);
}
}
return new RedirectResponse($redirect_url);
}
/**
* Saves data about the authentication.
*
* @param array $session_data
* Data to store in the session.
*
* Allowed keys for $session_data:
* - idp: the IdP which was used to authenticate.
* - session_index: the IdP’s session index from the response.
*
* @todo Remove $_SESSION at the next major version.
*/
function saml_sp_drupal_login_set_session(array $session_data) {
$session_vars = ['idp', 'session_index'];
$session = \Drupal::service('session');
$session->set('saml_sp__authenticated', TRUE);
foreach ($session_vars as $key) {
if (array_key_exists($key, $session_data)) {
$session->set('saml_sp__' . $key, $session_data[$key]);
}
}
$_SESSION['authenticated_via_saml_sp'] = TRUE;
}
/**
* Return whether the user is currently authenticated by the SAML SP module.
*
* @return bool
* TRUE if the user is currently authenticated.
*/
function saml_sp_drupal_login_is_authenticated() {
$session = \Drupal::service('session');
return $session->get('saml_sp__authenticated', FALSE);
}
/**
* Get the User object from either users table or custom field.
*
* Custom field should be used if the users need to be able to change the email
* address on IdP, because then it cannot be used for identifying a user.
* Email address can be used as a backup method if user is singing in for the
* first time and their NameID value has not been stored to the given field yet.
*
* @param string $name_id
* The NameID value which SSO server provides in SAML response.
* @param string $field_name
* The name of the field in Drupal where NameID is stored.
* @param mixed|NULL $emails
* User email addresses, which are only used if NameID cannot be found.
*
* @return \Drupal\user\UserInterface|false
* The user object in Drupal which matches the NameID or email address, or
* FALSE if it cannot be found.
*/
function saml_sp_drupal_login_get_user($name_id, $field_name, $emails = NULL) {
$user = FALSE;
// Email can have multiple values now; warn about the API deprecation.
if (!is_array($emails) && !empty($emails)) {
$emails = [$emails];
@trigger_error('Passing email as a string is deprecated in saml_sp:4.1.0 and will be removed in saml_sp:5.0', E_USER_DEPRECATED);
}
// Look in the obvious place for the obvious value.
if ($field_name === 'mail') {
$user = user_load_by_mail($name_id);
}
if ($user) {
return $user;
}
// If that failed, try to find the uid from the configured field. We have
// to look for standard fields on users plus custom fields.
if ($field_name !== 'mail') {
$db = \Drupal::database();
$schema = $db->schema();
if ($schema->fieldExists('users_field_data', $field_name)) {
$uid = $db->select('users_field_data')
->fields('users_field_data', ['uid'])
->condition($field_name, $name_id, '=')
->execute()
->fetchField();
}
elseif ($schema->tableExists('user__' . $field_name)) {
$uid = $db->select('user__' . $field_name, 'nameid')
->fields('nameid', ['entity_id'])
->condition($field_name . '_value', $name_id, '=')
->execute()
->fetchField();
}
}
if(!empty($uid)) {
return User::load($uid) ?? FALSE;
}
// We could not match NameID. If there are no email addresses returned
// from the IdP, we cannot fall back to matching them.
if (empty($emails)) {
return FALSE;
}
// Try to find any of the email addresses in the users table.
foreach ($emails as $email) {
$user = user_load_by_mail($email);
if ($user) {
return $user;
}
}
// No matches.
return FALSE;
}
/**
* Implements hook_user_logout().
*/
function saml_sp_user_logout($account) {
/*
// @codingStandardsIgnoreStart
// Load the IdP to authenticate against.
$idp = saml_sp_drupal_login__get_id();
// what is the authentication method?
switch ($idp->getAuthnContextClassRef()) {
case 'urn:federation:authentication:windows':
// the user is logged in through their Windows account
// it is impractical to log out of the IdP system as well
return;
break;
}
if (!variable_get('saml_sp_drupal_login__logout', TRUE)) {
// the site doesn't want the IdP to be signed out of,
// so just log out of Drupal
return;
}
global $language;
global $base_url;
// Settings is an array
$settings = saml_sp__get_settings($idp);
// Creating Saml2 Settings object from array
$saml_settings = new Settings($settings);
$idp_data = $saml_settings->getIdPData();
// Checking if logout url is configured
if (isset($idp_data['singleLogoutService']) && isset($idp_data['singleLogoutService']['url'])) {
$slo_url = $idp_data['singleLogoutService']['url'];
}
else {
throw new Exception("The IdP does not support Single Log Out");
}
// Creating a logout request to be passed to IdP
if (isset($_SESSION['IdPSessionIndex']) && !empty($_SESSION['IdPSessionIndex'])) {
$logout_request = new LogoutRequest($saml_settings, NULL, NULL ,$_SESSION['IdPSessionIndex']);
}
else {
$logout_request = new LogoutRequest($saml_settings);
}
$saml_request = $logout_request->getRequest();
$parameters = array('SAMLRequest' => $saml_request);
// Checking current language, so that user can be redirected to front page
// in same language
$parameters['RelayState'] = $base_url . '/' . $language->prefix;
$url = Utils::redirect($slo_url, $parameters, TRUE);
\Drupal::logger('saml_sp')->notice('Session closed for %name (%uid) and starting SAML SLO.', array('%name' => $account->name, '%uid' => $account->uid));
// Force redirection in drupal_goto().
unset($_GET['destination']);
if(!empty($saml_request)) {
drupal_goto($url);
}
// @codingStandardsIgnoreEnd
/**/
}
/**
* Updates user attributes from SAML data after successful login.
*
* @param \Drupal\user\UserInterface $user
* The logged-in user.
* @param mixed $emails
* The user's email address.
* @param array $attributes
* Other attributes returned from the IdP.
*
* @TODO: All of it.
*/
function saml_sp_drupal_login_update_user_attributes(UserInterface $user, $emails, array $attributes) {
// Email can have multiple values now; warn about the API deprecation.
if (!is_array($emails) && !empty($emails)) {
$emails = [$emails];
@trigger_error('Passing email as a string is deprecated in saml_sp:4.1.0 and will be removed in saml_sp:5.0', E_USER_DEPRECATED);
}
// Default language is the site default.
$language = \Drupal::languageManager()->getCurrentLanguage()->getId();
// If language attribute is set on IdP, then use that language.
if (isset($attributes['language'])) {
$language = $attributes['language'][0];
}
// Backwards compatibility to avoid a breaking change. Call the alter hook first.
\Drupal::moduleHandler()->alterDeprecated('hook_saml_sp_drupal_login_user_attributes_alter() is deprecated, use hook_saml_sp_drupal_login_user_attributes() instead.', 'saml_sp_drupal_login_user_attributes', $user, $attributes);
\Drupal::moduleHandler()->invokeAll('saml_sp_drupal_login_user_attributes', [$user, $attributes]);
/*
// @codingStandardsIgnoreStart
// Update email address if it has changed on IdP.
if (\Drupal::config('saml_sp_drupal_login.config')->get('update_email') && $user->mail != $email) {
\Drupal::logger('saml_sp')->notice('Updating email address from %old_email to %new_email for UID %uid', array('%old_email' => $user->mail, '%new_email' => $email, '%uid' => $user->uid));
$wrapper = entity_metadata_wrapper('user', $user);
$wrapper->mail->set($email);
$wrapper->save();
// Showing message for user about the update which happened on IdP.
$message = t('Your email address is now @new_email', array('@new_email' => $email));
\Drupal::messenger()->addMessage($message);
}
// Update language if it has changed on IdP.
if (\Drupal::config('saml_sp_drupal_login.config')->get('update_language') && $account->language != $language) {
\Drupal::logger('saml_sp')->notice('Updating language from %old_lang to %new_lang for UID %uid', array('%old_lang' => $user->language, '%new_lang' => $language, '%uid' => $user->uid));
$wrapper = entity_metadata_wrapper('user', $user);
$wrapper->language->set($language);
$wrapper->save();
}
// @codingStandardsIgnoreEnd
/**/
}
