475 lines
13 KiB
PHP
Executable file
475 lines
13 KiB
PHP
Executable file
<?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\App;
|
|
use Friendica\Content\Text\BBCode;
|
|
use Friendica\Core\Hook;
|
|
use Friendica\Core\Logger;
|
|
use Friendica\Core\Renderer;
|
|
use Friendica\Database\DBA;
|
|
use Friendica\DI;
|
|
use Friendica\Model\User;
|
|
use Friendica\Util\Strings;
|
|
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($a)
|
|
{
|
|
}
|
|
|
|
function saml_init(App $a)
|
|
{
|
|
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($a);
|
|
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) {
|
|
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_head(App $a, string &$body)
|
|
{
|
|
DI::page()->registerStylesheet(__DIR__ . '/saml.css');
|
|
}
|
|
|
|
function saml_footer(App $a, string &$body)
|
|
{
|
|
$fragment = addslashes(BBCode::convert(DI::config()->get('saml', 'settings_statement')));
|
|
$body .= <<<EOL
|
|
<script>
|
|
var target=$("#settings-nickname-desc");
|
|
if (target.length) { target.append("<p>$fragment</p>"); }
|
|
</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(App $a, array &$b)
|
|
{
|
|
if (!saml_is_configured()) {
|
|
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);
|
|
$_SESSION['AuthNRequestID'] = $auth->getLastRequestID();
|
|
header('Pragma: no-cache');
|
|
header('Cache-Control: no-cache, must-revalidate');
|
|
header('Location: ' . $ssoBuiltUrl);
|
|
exit();
|
|
}
|
|
|
|
function saml_sso_reply(App $a)
|
|
{
|
|
$auth = new \OneLogin\Saml2\Auth(saml_settings());
|
|
$requestID = null;
|
|
|
|
if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) {
|
|
$requestID = $_SESSION['AuthNRequestID'];
|
|
}
|
|
|
|
$auth->processResponse($requestID);
|
|
unset($_SESSION['AuthNRequestID']);
|
|
|
|
$errors = $auth->getErrors();
|
|
|
|
if (!empty($errors)) {
|
|
echo 'Errors encountered.';
|
|
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($a, $user);
|
|
}
|
|
|
|
if (isset($_POST['RelayState']) && Utils::getSelfURL() != $_POST['RelayState']) {
|
|
$auth->redirectTo($_POST['RelayState']);
|
|
}
|
|
}
|
|
|
|
function saml_slo_initiate(App $a, array &$b)
|
|
{
|
|
if (!saml_is_configured()) {
|
|
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();
|
|
$_SESSION['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 (isset($_SESSION) && isset($_SESSION['LogoutRequestID'])) {
|
|
$requestID = $_SESSION['LogoutRequestID'];
|
|
} else {
|
|
$requestID = null;
|
|
}
|
|
|
|
$auth->processSLO(false, $requestID);
|
|
|
|
$errors = $auth->getErrors();
|
|
|
|
if (empty($errors)) {
|
|
$auth->redirectTo(DI::baseUrl());
|
|
} else {
|
|
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(App $a, 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(App $a)
|
|
{
|
|
$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)) {
|
|
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) {
|
|
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,
|
|
]
|
|
];
|
|
}
|