diff options
author | Aleš Smodiš <aless@guru.si> | 2015-10-15 15:09:00 +0200 |
---|---|---|
committer | Aleš Smodiš <aless@guru.si> | 2015-10-15 15:09:00 +0200 |
commit | cbc096f9cb44a7d26b4fa01a40dbba594ab339ca (patch) | |
tree | 3079dbaf60e7e60d643e66d013fc52d155eaa095 /saml | |
parent | c8c4ca123d24757d3d3537817ba2a40849b3ca2c (diff) |
Implemented minimum support for authentication via SAML with an addition of a new daemon.
TODO: python login with SAML credentials.
Diffstat (limited to 'saml')
-rw-r--r-- | saml/.gitignore | 1 | ||||
-rw-r--r-- | saml/package.json | 13 | ||||
-rw-r--r-- | saml/saml.js | 642 |
3 files changed, 656 insertions, 0 deletions
diff --git a/saml/.gitignore b/saml/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/saml/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/saml/package.json b/saml/package.json new file mode 100644 index 0000000..f60d0a5 --- /dev/null +++ b/saml/package.json @@ -0,0 +1,13 @@ +{ + "name": "CodeQSaml", + "version": "0.0.1", + "dependencies": { + "bluebird": "^2.10.2", + "body-parser": "^1.14.1", + "express": "^4.13.3", + "log4js": "^0.6.27", + "xml-crypto": "^0.8.1", + "xmlbuilder": "^3.1.0", + "xmldom": "^0.1.19" + } +} diff --git a/saml/saml.js b/saml/saml.js new file mode 100644 index 0000000..a363c1e --- /dev/null +++ b/saml/saml.js @@ -0,0 +1,642 @@ +var fs = require('fs'), + url = require('url'), + zlib = require('zlib'), + querystring = require('querystring'), + Promise = require('bluebird'), + log4js = require('log4js'), // the logger + express = require('express'), + xmlCrypto = require('xml-crypto'), + xmldom = require('xmldom'), + xmlbuilder = require('xmlbuilder'), + crypto = require('crypto'), + bodyParser = require('body-parser'), + http_app = express(), + http_server = require('http').createServer(http_app); + +http_app.use(bodyParser.urlencoded({extended: true})); +http_app.use(bodyParser.json()); +http_server.listen(8085, 'localhost'); + +// ================================================================================ +// The logger +// ================================================================================ + +log4js.loadAppender('file'); +log4js.addAppender(log4js.appenders.file(process.env.CODEQ_SAML_LOG || 'codeq-saml.log'), 'log'); +var logger = log4js.getLogger('log'); + +var logException = function (msg, error) { + if ((error instanceof Error) && (typeof error.stack === 'string')) msg += '\n' + error.stack; + logger.error(msg); +}; + +// prevent the node from exiting because of an unhandled exception +Promise.onPossiblyUnhandledRejection(function (error) { + logException('Unhandled promise rejection: ' + error, error); +}); + +process.on('uncaughtException', function (error) { + logException('Uncaught exception: ' + error, error); +}); + +// ================================================================================ +// Misc +// ================================================================================ + +var assertionConsumerServiceURL = 'https://codeq.si/Shibboleth.sso/SAML2/POST', + SPIssuer = 'https://codeq.si/sp/201509281041', + privateCert = fs.readFileSync('/etc/shibboleth/sp-key.pem', 'utf8'), + SamlStatusSuccess = 'urn:oasis:names:tc:SAML:2.0:status:Success'; + +// IdP: idp.aai.arnes.si +var SSOServiceURL = 'https://idp.aai.arnes.si/simplesaml/saml2/idp/SSOService.php', + SLOServiceURL = 'https://idp.aai.arnes.si/simplesaml/saml2/idp/SingleLogoutService.php', + IdPIssuer = 'https://idp.aai.arnes.si/idp/20090116', + IdPKeyInfoProvider = new xmlCrypto.FileKeyInfo('/etc/shibboleth/idp.crt'); + +/* +// IdP: idp.uni-lj.si +var SSOServiceURL = 'https://idp.uni-lj.si/simplesaml/saml2/idp/SSOService.php', + SLOServiceURL = 'https://idp.uni-lj.si/simplesaml/saml2/idp/SingleLogoutService.php', + IdPIssuer = 'https://idp.uni-lj.si/idp/20100525', + IdPKeyInfoProvider = new xmlCrypto.FileKeyInfo('/etc/shibboleth/idp.uni-lj.si.crt'); + */ + +var idChars = '0123456789abcdef', + makeRequestId = function () { + var idParts = [], + i; + for (i = 0; i < 20; i++) idParts.push(idChars[Math.floor(Math.random() * 15)]); + return idParts.join(''); + }; + +var waitingLogins = {}, // maps sid to waiter + waitingLogouts = {}, + parkedLoginRequests = {}, // maps SAML request ID to waiter + parkedLogoutRequests = {}; + +var makeSamlRedirectUrl = function (serviceUrl, queryKey, queryObj, additionalData, callback) { + var xml = xmlbuilder.create(queryObj).end(); + logger.debug('Redirecting with ' + queryKey + ' to ' + serviceUrl + ':\n' + xml); + zlib.deflateRaw(xml, function (err, buffer) { + var svcUrl, signer, query, keys, i, key; + if (err) { + callback(err, null); + return; + } + query = { + 'SigAlg': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' + }; + query[queryKey] = buffer.toString('base64'); + if (additionalData && (typeof additionalData === 'object')) { + keys = Object.keys(additionalData); + for (i = keys.length - 1; i >= 0; i--) { + key = keys[i]; + query[key] = additionalData[key]; + } + } + signer = crypto.createSign('RSA-SHA1'); + signer.update(querystring.stringify(query)); + query.Signature = signer.sign(privateCert, 'base64'); + svcUrl = url.parse(serviceUrl); + svcUrl.query = query; + delete svcUrl.search; + callback(null, url.format(svcUrl)); + }); +}; + +var makeWaiter = function (sid) { + var waiter = { + 'sid': sid, + 'created': Date.now(), + 'listeners': 0 + }; + waiter.promise = new Promise(function (resolve, reject) { + waiter.resolve = resolve; + waiter.reject = reject; + }); + return waiter; +}; + +// ================================================================================ +// XML DOM data extraction functions for SAML data +// ================================================================================ + +var validateXmlSignature = function (xml, signatureNode) { + var sig = new xmlCrypto.SignedXml(); + sig.keyInfoProvider = IdPKeyInfoProvider; + sig.loadSignature(signatureNode.toString()); + return sig.checkSignature(xml); +}; + +var xmlStatusOk = function (statusNode) { + var node = statusNode.firstChild, + attributes, i; + while (node) { + if (node.localName === 'StatusCode') { + attributes = node.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + if (attributes[i].localName === 'Value') return attributes[i].value == SamlStatusSuccess; + } + } + node = node.nextSibling; + } + return null; +}; + +var parseAttributeValue = function (attributeNode) { + var node = attributeNode.firstChild; + while (node) { + if (node.localName === 'AttributeValue') { + return node.firstChild.data; + } + node = node.nextSibling; + } + return null; +}; + +var parseAttributes = function (attributeStatementNode) { + var node = attributeStatementNode.firstChild, + result = {}, + attributes, i, name; + while (node) { + if (node.localName === 'Attribute') { + attributes = node.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + if (attributes[i].localName === 'Name') { + name = attributes[i].value; + break; + } + } + if (i < 0) { + console.log('ERROR: an attribute without a name'); + } + else { + result[name] = parseAttributeValue(node); + } + } + node = node.nextSibling; + } + return result; +}; + +// return the NameID in the format for use with xmlbuilder +var parseNameID = function (NameIDNode) { + var result = {}, + i, attributes; + result['#text'] = NameIDNode.firstChild.data; + attributes = NameIDNode.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + switch (attributes[i].localName) { + case 'NameQualifier': // NameID namespace + result['@NameQualifier'] = attributes[i].value; + break; + case 'SPNameQualifier': // 2nd level NameID namespace + result['@SPNameQualifier'] = attributes[i].value; + break; + case 'Format': + result['@Format'] = attributes[i].value; + break; + case 'SPProvidedID': + result['@SPProvidedID'] = attributes[i].value; + break; + } + } + return result; +}; + +var parseSubject = function (subjectNode) { + var node = subjectNode.firstChild, + result = {}; + while (node) { + if (node.localName === 'NameID') { + result['NameID'] = parseNameID(node); + } + node = node.nextSibling; + } + return result; +}; + +var parseAssertion = function (xml, assertionNode) { + var node = assertionNode.firstChild, + result = {}, + attributes, i; + while (node) { + switch (node.localName) { + case 'Signature': + result.signatureOk = validateXmlSignature(xml, node); + break; + case 'Subject': + // extract presenter identifier + result.subject = parseSubject(node); + break; + case 'AuthnStatement': + // extract the session index + attributes = node.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + if (attributes[i].localName === 'SessionIndex') { + result.SessionIndex = attributes[i].value; + break; + } + } + break; + case 'AttributeStatement': + result.attributes = parseAttributes(node); + break; + } + node = node.nextSibling; + } + return result; +}; + +// ================================================================================ +// SAML logout handlers +// ================================================================================ + +// process response to our (global) logout request +var handleLogoutResponse = function (xml, form, res) { + var errors = [], + issuer = null, + status = null, + session = null, + doc, rootNode, attributes, i, inResponseTo, node; + doc = new xmldom.DOMParser().parseFromString(xml); + rootNode = doc.documentElement; + if (rootNode.localName != 'LogoutResponse') { + errors.push('not a LogoutResponse: ' + rootNode.localName); + } + else { + attributes = rootNode.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + if (attributes[i].localName === 'InResponseTo') { + inResponseTo = attributes[i].value; + break; + } + } + if (!inResponseTo) errors.push('request ID that this message is a response to is missing'); + else { + session = waitingLogouts[inResponseTo]; + if (!session) errors.push('there is no session waiting on request ID ' + inResponseTo); + } + node = rootNode.firstChild; + while (node) { + switch (node.localName) { + case 'Issuer': + issuer = node.firstChild.data; + break; + case 'Status': + status = xmlStatusOk(node); + break; + default: +// errors.push('Unknown/unsupported top XML node: ' + node.localName + ', prefix: ' + node.prefix); + break; + } + node = node.nextSibling; + } + if (issuer === null) errors.push('Issuer is not set'); + else if (issuer !== IdPIssuer) errors.push('Invalid IdP issuer: ' + issuer); + if (status === null) errors.push('response status is not set'); + else if (!status) errors.push('response status is not OK'); + } + res.set('Content-Type', 'text/plain'); + if (errors.length > 0) { + if (errors.length > 1) res.send('Multiple errors:\n ' + errors.join('\n ')); + else res.send('Error: ' + errors[0]); + session.samlLogout(errors.join('\n')); + } + else { + res.send('OK'); + session.samlLogout(null); + } +}; + +// a third party initiated the (global) logout, process the request and redirect with a response +var handleLogoutRequest = function (xml, form, res) { + var errors = [], + nameId = null, + sessionIndex = null, + doc, rootNode, attributes, i, requestId, node, + responseObj, responseId; + doc = new xmldom.DOMParser().parseFromString(xml); + rootNode = doc.documentElement; + if (rootNode.localName != 'LogoutRequest') { + errors.push('not a LogoutRequest: ' + rootNode.localName); + } + else { + attributes = rootNode.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + if (attributes[i].localName === 'ID') { + requestId = attributes[i].value; + break; + } + } + if (!requestId) errors.push('request ID is missing'); + node = rootNode.firstChild; + while (node) { + switch (node.localName) { + case 'Issuer': + if (node.firstChild.data !== IdPIssuer) errors.push('ERROR: invalid IdP issuer: ' + node.firstChild.data); + break; + case 'NameID': + nameId = parseNameID(node); + break; + case 'SessionIndex': + sessionIndex = node.firstChild && node.firstChild.data; + break; + } + node = node.nextSibling; + } + if (!nameId) errors.push('NameID not present'); + else { + if (!nameId['#text']) errors.push('NameID is empty'); + } + } + if (errors.length > 0) { + res.set('Content-Type', 'text/plain'); + res.send(errors.join('\n')); + } + else { + responseId = makeRequestId(); + responseObj = { + 'samlp:LogoutResponse': { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + '@Version': '2.0', + '@Destination': SLOServiceURL, + '@ID': responseId, + '@IssueInstant': new Date().toISOString(), + '@InResponseTo': requestId, + 'saml:Issuer' : { + '#text': SPIssuer + }, + 'samlp:Status': { + 'samlp:StatusCode': { + '@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success' + } + } + } + }; + makeSamlRedirectUrl(SLOServiceURL, 'SAMLRequest', responseObj, form.RelayState ? {RelayState: form.RelayState} : null, function (err, svcUrl) { + if (err) { + console.log('Error while assembling the Logout SAML response: ' + err); + res.send('Failed: ' + err); + return; + } + res.redirect(svcUrl); // redirect to the IdP + }); + } +}; + +// ================================================================================ +// SAML web request handlers +// ================================================================================ + +// redirect to login on Arnes AAI, https://codeq.si/Shibboleth.sso/Login +http_app.get('/Shibboleth.sso/Login', function (req, res) { + var sid = req.query.sid, + waiter, requestId, request; + try { + if (!sid) { + res.send('SID not set'); + return; + } + waiter = waitingLogins[sid]; + if (!waiter) waitingLogins[sid] = waiter = makeWaiter(sid); + + requestId = makeRequestId(); + request = { + 'samlp:AuthnRequest': { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + '@Version': '2.0', + '@ProtocolBinding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + '@AssertionConsumerServiceURL': assertionConsumerServiceURL, + '@Destination': SSOServiceURL, + '@ID': requestId, + '@IssueInstant': new Date().toISOString(), + 'saml:Issuer': { + '#text': SPIssuer + } + } + }; + makeSamlRedirectUrl(SSOServiceURL, 'SAMLRequest', request, null, function (err, svcUrl) { + if (err) { + logException('Error while assembling the Login SAML request: ' + err, err); + res.send('Failed: ' + err); + waiter.done = Date.now(); + waiter.reject(err instanceof Error ? err : new Error(err)); + return; + } + res.redirect(svcUrl); // redirect to the IdP + parkedLoginRequests[requestId] = waiter; + waiter.parked = Date.now(); + waiter.requestId = requestId; + }); + } + catch (e) { + logException('Exception while processing login request: ' + e, e); + res.send('Error: ' + e); + if (waiter) { + waiter.done = Date.now(); + waiter.reject(e); + } + } +}); + +// redirect to logout on Arnes AAI, https://codeq.si/Shibboleth.sso/Logout +http_app.get('/Shibboleth.sso/Logout', function (req, res) { + var sid = req.query.sid, + session, samlData, requestId, request; + if (!sid) { + res.send('SID not set'); + return; + } + session = sessions[sid]; + if (!session) { + res.send('No such session: ' + sid); + return; + } + samlData = session.saml; + if (!samlData) { + res.send('No SAML session associated with session ' + sid); + return; + } + if (!samlData.NameID) { + res.send('The SAML data for session ' + sid + ' does not contain the NameID'); + return; + } + + requestId = makeRequestId(); + request = { + 'samlp:LogoutRequest': { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + '@Version': '2.0', + '@Destination': SLOServiceURL, + '@ID': requestId, + '@IssueInstant': new Date().toISOString(), + 'saml:Issuer' : { + '#text': SPIssuer + }, + 'saml:NameID': samlData.NameID + } + }; + if (samlData.SessionIndex) requestObj['samlp:LogoutRequest']['samlp:SessionIndex'] = {'#text': samlData.SessionIndex}; + makeSamlRedirectUrl(SLOServiceURL, 'SAMLRequest', request, null, function (err, svcUrl) { + if (err) { + console.log('Error while assembling the Logout SAML request: ' + err); + res.send('Failed: ' + err); + return; + } + res.redirect(svcUrl); // redirect to the IdP + parkedLogoutRequests[requestId] = session; + }); +}); + +// user logged in, read the credentials +http_app.post('/Shibboleth.sso/SAML2/POST', function (req, res) { + var form = req.body, + keys = [], + errors = [], + inResponseTo = null, + issuer = null, + signature = null, + status = null, + assertion = null, + waiter = null, + attributes, i, key, value, xml, doc, rootNode, node, NameID, Format, SPNameQualifier, SessionIndex, samlData, userData; + value = form.SAMLResponse; + if (!value) { + errors.push('no SAMLResponse'); + } + else { + xml = new Buffer(value, 'base64').toString('utf8'); + doc = new xmldom.DOMParser().parseFromString(xml); + rootNode = doc.documentElement; + if (rootNode.localName != 'Response') { + errors.push('the root XML node is not a Response: ' + rootNode.localName); + } + else { + attributes = rootNode.attributes; + for (i = attributes.length - 1; i >= 0; i--) { + if (attributes[i].localName === 'InResponseTo') { + inResponseTo = attributes[i].value; + break; + } + } + if (!inResponseTo) errors.push('request ID that this message is a response to is missing'); + else { + waiter = parkedLoginRequests[inResponseTo]; + if (!waiter) errors.push('there is no session waiting on request ID ' + inResponseTo); + else { + waiter.done = Date.now(); + delete parkedLoginRequests[inResponseTo]; + } + node = rootNode.firstChild; + while (node) { + switch (node.localName) { + case 'Issuer': + issuer = node.firstChild.data; + break; + case 'Signature': + signature = validateXmlSignature(xml, node); + break; + case 'Status': + status = xmlStatusOk(node); + break; + case 'Assertion': + assertion = parseAssertion(xml, node); + break; + default: +// errors.push('Unknown/unsupported top XML node: ' + node.localName + ', prefix: ' + node.prefix + '<br>\n'); + break; + } + node = node.nextSibling; + } + if (issuer === null) errors.push('Issuer is not set'); + else if (issuer !== IdPIssuer) errors.push('Invalid IdP issuer: ' + issuer); + if (signature === null) errors.push('the response is not signed'); + else if (!signature) errors.push('IdP signature is invalid'); + if (status === null) errors.push('status is not set'); + else if (!status) errors.push('response status is not OK'); + if (!assertion) errors.push('no assertions supplied, no attributes present'); + else { + if ((typeof assertion.signatureOk !== 'undefined') && (!assertion.signatureOk)) errors.push('assertion signature is invalid'); + samlData = {}; + node = assertion.subject; + if (!node) errors.push('assertion subject is not set'); + else { + node = node.NameID; + if (!node) errors.push('assertion subject\'s NameID is not set'); + else { + if (!node['#text']) errors.push('assertion subject\'s NameID is empty'); + samlData['NameID'] = node; + } + } + if (assertion.SessionIndex) samlData['SessionIndex'] = assertion.SessionIndex; + node = assertion.attributes; + if (!node) errors.push('no attributes supplied'); + else { + userData = {}; + samlData.userData = userData; + keys = Object.keys(node); + for (i = keys.length - 1; i >= 0; i--) { + key = keys[i]; + userData[key] = node[key]; + } + } + } + } + } + } + res.set('Content-Type', 'text/plain'); + if (errors.length > 0) { + if (errors.length > 1) res.send('Multiple errors:\n ' + errors.join('\n ')); + else res.send('Error: ' + errors[0]); + if (waiter) waiter.reject(new Error(errors.join(', '))); + } + else { + res.send('OK'); + if (waiter) waiter.resolve(samlData); + } +}); + +// user logged out, the GET service +http_app.get('/Shibboleth.sso/SLO/Redirect', function (req, res) { + var form = req.query, + delegateToHandler = function (err, buffer, handler) { + if (err) { + console.log('Error while deflating request: ' + err); + res.send('Failed: ' + err); + return; + } + handler(buffer.toString('utf8'), form, res); + }; + if (form.SAMLRequest) zlib.inflateRaw(new Buffer(form.SAMLRequest, 'base64'), function (err, buffer) {delegateToHandler(err, buffer, handleLogoutRequest);}); + else if (form.SAMLResponse) zlib.inflateRaw(new Buffer(form.SAMLResponse, 'base64'), function (err, buffer) {delegateToHandler(err, buffer, handleLogoutResponse);}); + else res.send('Invalid SAML logout request.'); +}); + +// ================================================================================ +// Web API handler +// ================================================================================ + +http_app.get('/Shibboleth.sso/WaitLogin', function (req, res) { + var sid = req.query.sid, + waiter; + if (!sid) { + res.set('Content-Type', 'text/plain'); + res.send('Provide sid as a query parameter'); + return; + } + waiter = waitingLogins[sid]; + if (!waiter) waitingLogins[sid] = waiter = makeWaiter(sid); + waiter.listeners = waiter.listeners + 1; + waiter.promise.then(function (jsonResponse) { + res.json({'code': 0, 'message': 'OK', 'data': jsonResponse}); + delete waitingLogins[sid]; + }, function (err) { + res.json({'code': 1, 'message': '' + err}); + delete waitingLogins[sid]; + }).done(); +}); |