<?php
/*
 * Name: SAML SSO and SLO
 * Description: replace login and registration with a SAML identity provider.
 * Version: 1.0
 * Author: Ryan <https://friendica.verya.pe/profile/ryan>
 */

use Friendica\Content\Text\BBCode;
use Friendica\Core\Hook;
use Friendica\Core\Renderer;
use Friendica\Database\DBA;
use Friendica\DI;
use Friendica\Model\User;
use OneLogin\Saml2\Utils;

require_once(__DIR__ . '/vendor/autoload.php');

define('PW_LEN', 32); // number of characters to use for random passwords

function saml_module() {}

function saml_init()
{
	if (DI::args()->getArgc() < 2) {
		return;
	}

	if (!saml_is_configured()) {
		echo 'Please configure the SAML add-on via the admin interface.';
		return;
	}

	switch (DI::args()->get(1)) {
		case 'metadata.xml':
			saml_metadata();
			break;
		case 'sso':
			saml_sso_reply();
			break;
		case 'slo':
			saml_slo_reply();
			break;
	}
	exit();
}

function saml_metadata()
{
	try {
		$settings = new \OneLogin\Saml2\Settings(saml_settings());
		$metadata = $settings->getSPMetadata();
		$errors = $settings->validateMetadata($metadata);

		if (empty($errors)) {
			header('Content-Type: text/xml');
			echo $metadata;
		} else {
			throw new \OneLogin\Saml2\Error(
				'Invalid SP metadata: '.implode(', ', $errors),
				\OneLogin\Saml2\Error::METADATA_SP_INVALID
			);
		}
	} catch (Exception $e) {
		DI::logger()->error($e->getMessage());
	}
}

function saml_install()
{
	Hook::register('login_hook', __FILE__, 'saml_sso_initiate');
	Hook::register('logging_out', __FILE__, 'saml_slo_initiate');
	Hook::register('head', __FILE__, 'saml_head');
	Hook::register('footer', __FILE__, 'saml_footer');
}

function saml_footer(string &$body)
{
	$fragment = addslashes(BBCode::convertForUriId(User::getSystemUriId(), DI::config()->get('saml', 'settings_statement')));
	$samlhint = DI::l10n()->t('managed via SAML authentication');
	$body .= <<<EOL
<script>
var target=$("#settings-nickname-desc");
if (target.length) { target.append("<p>$fragment</p>"); }
var saml_hint = document.createElement("span");
var saml_hint_text = document.createTextNode('$samlhint');
saml_hint.appendChild(saml_hint_text);
if ( document.getElementById('id_email') != null ) {
	document.getElementById('id_email').setAttribute('readonly', 'readonly');
	document.getElementById('id_email').parentNode.insertBefore(saml_hint, document.getElementById('id_email').nextSibling);
}
// Frio theme
if ( document.getElementById('password-settings-collapse') != null ) {
	document.getElementById('password-settings-collapse').replaceChildren(saml_hint.cloneNode(true));
}
if ( document.getElementById('id_mpassword_wrapper') != null ) {
	document.getElementById('id_mpassword_wrapper').parentNode.appendChild(saml_hint.cloneNode(true));
	document.getElementById('id_mpassword_wrapper').remove();
	document.getElementById('id_email').nextElementSibling.classList.add('help-block');
}
// Vier theme
if ( document.getElementById('wrapper_mpassword') != null ) {
	document.getElementById('wrapper_mpassword').remove();
	document.getElementById('id_email').nextElementSibling.classList.add('field_help');
}
if ( document.getElementById('wrapper_password') != null ) {
	document.getElementById('wrapper_password').parentNode.replaceChildren(saml_hint.cloneNode(true));
}
</script>
EOL;
}

function saml_is_configured()
{
	return
		DI::config()->get('saml', 'idp_id') &&
		DI::config()->get('saml', 'client_id') &&
		DI::config()->get('saml', 'sso_url') &&
		DI::config()->get('saml', 'slo_request_url') &&
		DI::config()->get('saml', 'slo_response_url') &&
		DI::config()->get('saml', 'sp_key') &&
		DI::config()->get('saml', 'sp_cert') &&
		DI::config()->get('saml', 'idp_cert');
}

function saml_sso_initiate(string &$body)
{
	if (!saml_is_configured()) {
		DI::logger()->warning('SAML SSO tried to trigger, but the SAML addon is not configured yet!');
		return;
	}

	$auth = new \OneLogin\Saml2\Auth(saml_settings());
	$ssoBuiltUrl = $auth->login(null, [], false, false, true);
	DI::session()->set('AuthNRequestID', $auth->getLastRequestID());
	header('Pragma: no-cache');
	header('Cache-Control: no-cache, must-revalidate');
	header('Location: ' . $ssoBuiltUrl);
	exit();
}

function saml_sso_reply()
{
	$auth = new \OneLogin\Saml2\Auth(saml_settings());
	$requestID = null;

	if (DI::session()->exists('AuthNRequestID')) {
		$requestID = DI::session()->get('AuthNRequestID');
	}

	$auth->processResponse($requestID);
	DI::session()->remove('AuthNRequestID');

	$errors = $auth->getErrors();

	if (!empty($errors)) {
		echo 'Errors encountered.';
		DI::logger()->error(implode(', ', $errors));
		exit();
	}

	if (!$auth->isAuthenticated()) {
		echo 'Not authenticated';
		exit();
	}

	$username = $auth->getNameId();
	$email = $auth->getAttributeWithFriendlyName('email')[0];
	$name = $auth->getAttributeWithFriendlyName('givenName')[0];
	$last_name = $auth->getAttributeWithFriendlyName('surname')[0];

	if (strlen($last_name)) {
		$name .= " $last_name";
	}

	if (!DBA::exists('user', ['nickname' => $username])) {
		$user = saml_create_user($username, $email, $name);
	} else {
		$user = User::getByNickname($username);
	}

	if (!empty($user['uid'])) {
		DI::auth()->setForUser($user);
	}

	if (isset($_POST['RelayState']) && Utils::getSelfURL() != $_POST['RelayState']) {
		$auth->redirectTo($_POST['RelayState']);
	}
}

function saml_slo_initiate()
{
	if (!saml_is_configured()) {
		DI::logger()->warning('SAML SLO tried to trigger, but the SAML addon is not configured yet!');
		return;
	}

	$auth = new \OneLogin\Saml2\Auth(saml_settings());

	$sloBuiltUrl = $auth->logout();
	DI::session()->set('LogoutRequestID', $auth->getLastRequestID());
	header('Pragma: no-cache');
	header('Cache-Control: no-cache, must-revalidate');
	header('Location: ' . $sloBuiltUrl);
	exit();
}

function saml_slo_reply()
{
	$auth = new \OneLogin\Saml2\Auth(saml_settings());

	if (DI::session()->exists('LogoutRequestID')) {
		$requestID = DI::session()->get('LogoutRequestID');
	} else {
		$requestID = null;
	}

	$auth->processSLO(false, $requestID);

	$errors = $auth->getErrors();

	if (empty($errors)) {
		$auth->redirectTo(DI::baseUrl());
	} else {
		DI::logger()->error(implode(', ', $errors));
	}
}

function saml_input($key, $label, $description)
{
	return [
		'$' . $key => [
			$key,
			$label,
			DI::config()->get('saml', $key),
			$description,
			true, // all the fields are required
		]
	];
}

function saml_addon_admin(string &$o)
{
	$form =
		saml_input(
			'settings_statement',
			DI::l10n()->t('Settings statement'),
			DI::l10n()->t('A statement on the settings page explaining where the user should go to change '
					. 'their e-mail and password. BBCode allowed.')
		) +
		saml_input(
			'idp_id',
			DI::l10n()->t('IdP ID'),
			DI::l10n()->t('Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).')
		) +
		saml_input(
			'client_id',
			DI::l10n()->t('Client ID'),
			DI::l10n()->t('Identifier assigned to client by the identity provider (IdP).')
		) +
		saml_input(
			'sso_url',
			DI::l10n()->t('IdP SSO URL'),
			DI::l10n()->t('The URL for your identity provider\'s SSO endpoint.')
		) +
		saml_input(
			'slo_request_url',
			DI::l10n()->t('IdP SLO request URL'),
			DI::l10n()->t('The URL for your identity provider\'s SLO request endpoint.')
		) +
		saml_input(
			'slo_response_url',
			DI::l10n()->t('IdP SLO response URL'),
			DI::l10n()->t('The URL for your identity provider\'s SLO response endpoint.')
		) +
		saml_input(
			'sp_key',
			DI::l10n()->t('SP private key'),
			DI::l10n()->t('The private key the addon should use to authenticate.')
		) +
		saml_input(
			'sp_cert',
			DI::l10n()->t('SP certificate'),
			DI::l10n()->t('The certficate for the addon\'s private key.')
		) +
		saml_input(
			'idp_cert',
			DI::l10n()->t('IdP certificate'),
			DI::l10n()->t('The x509 certficate for your identity provider.')
		) +
		[
			'$submit'  => DI::l10n()->t('Save Settings'),
		];
	$t = Renderer::getMarkupTemplate('admin.tpl', 'addon/saml/');
	$o = Renderer::replaceMacros($t, $form);
}

function saml_addon_admin_post()
{
	$set = function ($key) {
		$val = (!empty($_POST[$key]) ? trim($_POST[$key]) : '');
		DI::config()->set('saml', $key, $val);
	};
	$set('idp_id');
	$set('client_id');
	$set('sso_url');
	$set('slo_request_url');
	$set('slo_response_url');
	$set('sp_key');
	$set('sp_cert');
	$set('idp_cert');
	$set('settings_statement');
}

function saml_create_user($username, $email, $name)
{
	if (!strlen($email) || !strlen($name)) {
		DI::logger()->error('Could not create user: no email or username given.');
		return false;
	}

	try {
		$strong = false;
		$bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong);

		if (!$strong) {
			throw new Exception('Strong algorithm not available for PRNG.');
		}

		$user = User::create([
			'username' => $name,
			'nickname' => $username,
			'email'	=> $email,
			'password' => base64_encode($bytes), // should be at least PW_LEN long
			'verified' => true
		]);

		return $user;
	} catch (Exception $e) {
		DI::logger()->error(
			'Exception while creating user',
			[
				'username'  => $username,
				'email'	 => $email,
				'name'	  => $name,
				'exception' => $e->getMessage(),
				'trace'	 => $e->getTraceAsString()
			]
		);

		return false;
	}
}

function saml_settings()
{
	return [

		// If 'strict' is True, then the PHP Toolkit will reject unsigned
		// or unencrypted messages if it expects them to be signed or encrypted.
		// Also it will reject the messages if the SAML standard is not strictly
		// followed: Destination, NameId, Conditions ... are validated too.
		// Should never be set to anything else in production!
		'strict' => true,

		// Enable debug mode (to print errors).
		'debug' => false,

		// Set a BaseURL to be used instead of try to guess
		// the BaseURL of the view that process the SAML Message.
		// Ex http://sp.example.com/
		//	http://example.com/sp/
		'baseurl' => DI::baseUrl() . '/saml',

		// Service Provider Data that we are deploying.
		'sp' => [

			// Identifier of the SP entity  (must be a URI)
			'entityId' => DI::config()->get('saml', 'client_id'),

			// Specifies info about where and how the <AuthnResponse> message MUST be
			// returned to the requester, in this case our SP.
			'assertionConsumerService' => [

				// URL Location where the <Response> from the IdP will be returned
				'url' => DI::baseUrl() . '/saml/sso',

				// SAML protocol binding to be used when returning the <Response>
				// message. OneLogin Toolkit supports this endpoint for the
				// HTTP-POST binding only.
				'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
			],

			// If you need to specify requested attributes, set a
			// attributeConsumingService. nameFormat, attributeValue and
			// friendlyName can be omitted
			'attributeConsumingService'=> [
				'serviceName' => 'Friendica SAML SSO and SLO Addon',
				'serviceDescription' => 'SLO and SSO support for Friendica',
				'requestedAttributes' => [
					[
						'uid' => '',
						'isRequired' => false,
					]
				]
			],

			// Specifies info about where and how the <Logout Response> message MUST be
			// returned to the requester, in this case our SP.
			'singleLogoutService' => [

				// URL Location where the <Response> from the IdP will be returned
				'url' => DI::baseUrl() . '/saml/slo',

				// SAML protocol binding to be used when returning the <Response>
				// message. OneLogin Toolkit supports the HTTP-Redirect binding
				// only for this endpoint.
				'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
			],

			// Specifies the constraints on the name identifier to be used to
			// represent the requested subject.
			// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported.
			'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',

			// Usually x509cert and privateKey of the SP are provided by files placed at
			// the certs folder. But we can also provide them with the following parameters
			'x509cert' => DI::config()->get('saml', 'sp_cert'),
			'privateKey' => DI::config()->get('saml', 'sp_key'),
		],

		// Identity Provider Data that we want connected with our SP.
		'idp' => [

			// Identifier of the IdP entity  (must be a URI)
			'entityId' => DI::config()->get('saml', 'idp_id'),

			// SSO endpoint info of the IdP. (Authentication Request protocol)
			'singleSignOnService' => [

				// URL Target of the IdP where the Authentication Request Message
				// will be sent.
				'url' => DI::config()->get('saml', 'sso_url'),

				// SAML protocol binding to be used when returning the <Response>
				// message. OneLogin Toolkit supports the HTTP-Redirect binding
				// only for this endpoint.
				'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
			],

			// SLO endpoint info of the IdP.
			'singleLogoutService' => [

				// URL Location of the IdP where SLO Request will be sent.
				'url' => DI::config()->get('saml', 'slo_request_url'),

				// URL location of the IdP where SLO Response will be sent (ResponseLocation)
				// if not set, url for the SLO Request will be used
				'responseUrl' => DI::config()->get('saml', 'slo_response_url'),

				// SAML protocol binding to be used when returning the <Response>
				// message. OneLogin Toolkit supports the HTTP-Redirect binding
				// only for this endpoint.
				'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
			],

			// Public x509 certificate of the IdP
			'x509cert' => DI::config()->get('saml', 'idp_cert'),
		],
		'security' => [
			'wantXMLValidation' => false,

			// Indicates whether the <samlp:AuthnRequest> messages sent by this SP
			// will be signed.  [Metadata of the SP will offer this info]
			'authnRequestsSigned' => true,

			// Indicates whether the <samlp:logoutRequest> messages sent by this SP
			// will be signed.
			'logoutRequestSigned' => true,

			// Indicates whether the <samlp:logoutResponse> messages sent by this SP
			// will be signed.
			'logoutResponseSigned' => true,

			// Sign the Metadata
			'signMetadata' => true,
		]
	];
}
