friendica-addons/saml/saml.php

470 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\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 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) {
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(string &$body)
{
DI::page()->registerStylesheet(__DIR__ . '/saml.css');
}
function saml_footer(string &$body)
{
$fragment = addslashes(BBCode::convertForUriId(User::getSystemUriId(), 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(string &$body)
{
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);
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.';
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(DI::app(), $user);
}
if (isset($_POST['RelayState']) && Utils::getSelfURL() != $_POST['RelayState']) {
$auth->redirectTo($_POST['RelayState']);
}
}
function saml_slo_initiate()
{
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();
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 {
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)) {
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,
]
];
}