From 147538fa8ea4e2504415b7bb7f4b1c5d632a4dc1 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 4 May 2020 11:33:05 +0200 Subject: [PATCH] Add saml2 auth support --- peertube-plugin-auth-saml2/README.md | 3 + peertube-plugin-auth-saml2/main.js | 294 +++++++++++++++++++ peertube-plugin-auth-saml2/package-lock.json | 141 +++++++++ peertube-plugin-auth-saml2/package.json | 24 ++ 4 files changed, 462 insertions(+) create mode 100644 peertube-plugin-auth-saml2/README.md create mode 100644 peertube-plugin-auth-saml2/main.js create mode 100644 peertube-plugin-auth-saml2/package-lock.json create mode 100644 peertube-plugin-auth-saml2/package.json diff --git a/peertube-plugin-auth-saml2/README.md b/peertube-plugin-auth-saml2/README.md new file mode 100644 index 0000000..bcbf14f --- /dev/null +++ b/peertube-plugin-auth-saml2/README.md @@ -0,0 +1,3 @@ +# SAML2 auth plugin for PeerTube + +Add SAML2 support to login form in PeerTube. diff --git a/peertube-plugin-auth-saml2/main.js b/peertube-plugin-auth-saml2/main.js new file mode 100644 index 0000000..33a5d99 --- /dev/null +++ b/peertube-plugin-auth-saml2/main.js @@ -0,0 +1,294 @@ +const saml2 = require('saml2-js') +const crypto = require('crypto') + +const store = { + assertUrl: null, + authDisplayName: 'SAML 2', + serviceProvider: null, + identityProvider: null +} + +async function register ({ + registerExternalAuth, + unregisterExternalAuth, + registerSetting, + settingsManager, + storageManager, + peertubeHelpers, + getRouter +}) { + const { logger } = peertubeHelpers + + const metadataUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-saml2/router/metadata.xml' + + registerSetting({ + name: 'client-id', + label: 'Client ID', + type: 'input', + private: true, + default: metadataUrl + }) + + registerSetting({ + name: 'sign-get-request', + label: 'Sign get request', + type: 'input-checkbox', + private: true, + default: false + }) + + registerSetting({ + name: 'auth-display-name', + label: 'Auth display name', + type: 'input', + private: true, + default: 'SAML 2' + }) + + registerSetting({ + name: 'login-url', + label: 'SSO login URL', + type: 'input', + private: true + }) + + registerSetting({ + name: 'provider-certificate', + label: 'Identity provider certificate', + type: 'input-textarea', + private: true + }) + + registerSetting({ + name: 'username-property', + label: 'Username property', + type: 'input', + private: true, + default: 'preferred_username' + }) + + registerSetting({ + name: 'mail-property', + label: 'Email property', + type: 'input', + private: true, + default: 'email' + }) + + registerSetting({ + name: 'display-name-property', + label: 'Display name property', + type: 'input', + private: true + }) + + registerSetting({ + name: 'role-property', + label: 'Role property', + type: 'input', + private: true + }) + + const router = getRouter() + + store.assertUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-saml2/router/assert' + router.post('/assert', (req, res) => handleAssert(peertubeHelpers, settingsManager, req, res)) + + router.get('/metadata.xml', (req, res) => { + if (!store.serviceProvider) { + logger.warn('Cannot get SAML 2 metadata: service provider not created.') + return res.sendStatus(400) + } + + res.type('application/xml').send(store.serviceProvider.create_metadata()) + }) + + await loadSettingsAndCreateProviders(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager, storageManager) + store.authDisplayName = await settingsManager.getSetting('auth-display-name') + + settingsManager.onSettingsChange(settings => { + loadSettingsAndCreateProviders(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager, storageManager) + .catch(err => logger.error('Cannot load settings and create client after settings changes.', { err })) + + if (settings['auth-display-name']) store.authDisplayName = settings['auth-display-name'] + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ############################################################################ + +async function loadSettingsAndCreateProviders ( + registerExternalAuth, + unregisterExternalAuth, + peertubeHelpers, + settingsManager, + storageManager +) { + const { logger } = peertubeHelpers + + if (store.serviceProvider || store.identityProvider) { + unregisterExternalAuth('saml2') + } + + store.serviceProvider = null + store.identityProvider = null + + const settings = await settingsManager.getSettings([ + 'client-id', + 'sign-get-request', + 'login-url', + 'provider-certificate' + ]) + + if (!settings['login-url']) { + logger.info('Do not register external saml2 auth because login URL is not set.') + return + } + + if (!settings['provider-certificate']) { + logger.info('Do not register external saml2 auth because provider certificate is not set.') + return + } + + const { publicKey: servicePublicKey, privateKey: servicePrivateKey } = await lazyLoadServiceCertificates(peertubeHelpers, storageManager) + + const serviceOptions = { + entity_id: settings['client-id'], + private_key: servicePrivateKey, + certificate: servicePublicKey, + assert_endpoint: store.assertUrl + } + store.serviceProvider = new saml2.ServiceProvider(serviceOptions) + + const identityOptions = { + sso_login_url: settings['login-url'], + certificates: [ + settings['provider-certificate'] + ], + sign_get_request: settings['sign-get-request'], + allow_unencrypted_assertion: true + } + store.identityProvider = new saml2.IdentityProvider(identityOptions) + + const result = registerExternalAuth({ + authName: 'saml2', + authDisplayName: () => store.authDisplayName, + onAuthRequest: async (req, res) => { + store.serviceProvider.create_login_request_url(store.identityProvider, {}, (err, loginUrl, requestId) => { + if (err) { + logger.error('Cannot SAML 2 authenticate.', { err }) + return redirectOnError(res) + } + + res.redirect(loginUrl) + }) + } + }) + + store.userAuthenticated = result.userAuthenticated +} + +function handleAssert(peertubeHelpers, settingsManager, req, res) { + const { logger } = peertubeHelpers + + const options = { request_body: req.body } + + store.serviceProvider.post_assert(store.identityProvider, options, async (err, samlResponse) => { + if (err) { + logger.error('Error SAML 2 assert.', { err }) + return redirectOnError(res) + } + + logger.debug('User authenticated by SAML 2.', { samlResponse }) + + try { + const user = await buildUser(settingsManager, samlResponse.user) + + return store.userAuthenticated({ + req, + res, + ...user + }) + } catch (err) { + logger.error('Error SAML 2 build user.', { err }) + return redirectOnError(res) + } + }); +} + +function redirectOnError (res) { + res.redirect('/login?externalAuthError=true') +} + +function findInUser (samlUser, key) { + if (!key) return undefined + + if (samlUser[key]) return samlUser[key] + + if (samlUser.attributes[key]) return samlUser.attributes[key][0] + + return undefined +} + +async function buildUser (settingsManager, samlUser) { + const settings = await settingsManager.getSettings([ + 'mail-property', + 'username-property', + 'display-name-property', + 'role-property' + ]) + + let username = findInUser(samlUser, settings['username-property']) || '' + username = username.replace(/[^a-z0-9._]/g, '_') + + return { + username, + email: findInUser(samlUser, settings['mail-property']), + displayName: findInUser(samlUser, settings['display-name-property']), + role: findInUser(samlUser, settings['role-property']) + } +} + +async function lazyLoadServiceCertificates (peertubeHelpers, storageManager) { + const { logger } = peertubeHelpers + + let privateKey = await storageManager.getData('service-private-key') + let publicKey = await storageManager.getData('service-public-key') + + if (!privateKey || !publicKey) { + logger.info('Generating public/private keys for SAML 2.') + + return new Promise((res, rej) => { + const options = { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem' + } + } + + crypto.generateKeyPair('rsa', options, (err, publicKey, privateKey) => { + if (err) return rej(err) + + Promise.all([ + storageManager.storeData('service-private-key', privateKey), + storageManager.storeData('service-public-key', publicKey) + ]).then(() => res({ publicKey, privateKey })) + }) + }) + } + + return { privateKey, publicKey } +} diff --git a/peertube-plugin-auth-saml2/package-lock.json b/peertube-plugin-auth-saml2/package-lock.json new file mode 100644 index 0000000..c6819ab --- /dev/null +++ b/peertube-plugin-auth-saml2/package-lock.json @@ -0,0 +1,141 @@ +{ + "name": "peertube-plugin-auth-saml2", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ejs": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz", + "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==" + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash-node": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash-node/-/lodash-node-2.4.1.tgz", + "integrity": "sha1-6oL3sQDHM9GkKvdoAeUGEF4qgOw=" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node-forge": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", + "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==" + }, + "saml2-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/saml2-js/-/saml2-js-2.0.5.tgz", + "integrity": "sha512-8OOZxeMxpdtEszFbvR01TcjKb8g/HsE1si/EGdTawSQY0KgwtvPTG2Ctc5xhEW45xhU+j+UrU5ttZxC72YXSUQ==", + "requires": { + "async": "^2.5.0", + "debug": "^2.6.0", + "underscore": "^1.8.0", + "xml-crypto": "^0.10.0", + "xml-encryption": "^0.11.0", + "xml2js": "^0.4.0", + "xmlbuilder": "~2.2.0", + "xmldom": "^0.1.0" + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "underscore": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz", + "integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg==" + }, + "xml-crypto": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-0.10.1.tgz", + "integrity": "sha1-+DL3TM9W8kr8rhFjofyrRNlndKg=", + "requires": { + "xmldom": "=0.1.19", + "xpath.js": ">=0.0.3" + }, + "dependencies": { + "xmldom": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.19.tgz", + "integrity": "sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=" + } + } + }, + "xml-encryption": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-0.11.2.tgz", + "integrity": "sha512-jVvES7i5ovdO7N+NjgncA326xYKjhqeAnnvIgRnY7ROLCfFqEDLwP0Sxp/30SHG0AXQV1048T5yinOFyvwGFzg==", + "requires": { + "async": "^2.1.5", + "ejs": "^2.5.6", + "node-forge": "^0.7.0", + "xmldom": "~0.1.15", + "xpath": "0.0.27" + } + }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "dependencies": { + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + } + } + }, + "xmlbuilder": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-2.2.1.tgz", + "integrity": "sha1-kyZDDxMNh0NdTECGZDqikm4QWjI=", + "requires": { + "lodash-node": "~2.4.1" + } + }, + "xmldom": { + "version": "0.1.31", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz", + "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==" + }, + "xpath": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz", + "integrity": "sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==" + }, + "xpath.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz", + "integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==" + } + } +} diff --git a/peertube-plugin-auth-saml2/package.json b/peertube-plugin-auth-saml2/package.json new file mode 100644 index 0000000..a150eac --- /dev/null +++ b/peertube-plugin-auth-saml2/package.json @@ -0,0 +1,24 @@ +{ + "name": "peertube-plugin-auth-saml2", + "version": "0.0.1", + "description": "Add SAML 2 support to login form in PeerTube.", + "engine": { + "peertube": ">=2.2.0" + }, + "keywords": [ + "peertube", + "plugin", + "auth" + ], + "homepage": "https://framagit.org/framasoft/peertube/official-plugins/tree/master/peertube-plugin-auth-saml2", + "author": "Chocobozzz", + "bugs": "https://framagit.org/framasoft/peertube/official-plugins/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {}, + "dependencies": { + "saml2-js": "^2.0.5" + } +}