From 4a781b21db10f82e35b9945109b5f4d41ad0e8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Smodi=C5=A1?= Date: Thu, 15 Oct 2015 18:46:54 +0200 Subject: Server-side support for SAML logout, sessions are destroyed only using an AJAX call. --- saml/saml.js | 156 ++++++++++++++++++++++++++-------------- server/handlers.py | 13 +++- server/user_session.py | 15 ++++ web/main.js | 192 ++++++++++++++++++++++++++++++------------------- 4 files changed, 246 insertions(+), 130 deletions(-) diff --git a/saml/saml.js b/saml/saml.js index a363c1e..43844f2 100644 --- a/saml/saml.js +++ b/saml/saml.js @@ -118,6 +118,15 @@ var makeWaiter = function (sid) { return waiter; }; +var makeDoubleWaiter = function (sid) { + var waiter = makeWaiter(sid); + waiter.responsePromise = new Promise(function (resolve, reject) { + waiter.responseResolve = resolve; + waiter.responseReject = reject; + }); + return waiter; +}; + // ================================================================================ // XML DOM data extraction functions for SAML data // ================================================================================ @@ -169,7 +178,7 @@ var parseAttributes = function (attributeStatementNode) { } } if (i < 0) { - console.log('ERROR: an attribute without a name'); + logger.error('ERROR: an attribute without a name'); } else { result[name] = parseAttributeValue(node); @@ -258,7 +267,7 @@ var handleLogoutResponse = function (xml, form, res) { var errors = [], issuer = null, status = null, - session = null, + waiter = null, doc, rootNode, attributes, i, inResponseTo, node; doc = new xmldom.DOMParser().parseFromString(xml); rootNode = doc.documentElement; @@ -275,8 +284,12 @@ var handleLogoutResponse = function (xml, form, res) { } 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); + waiter = parkedLogoutRequests[inResponseTo]; + if (!waiter) errors.push('there is no session waiting on request ID ' + inResponseTo); + else { + waiter.done = Date.now(); + delete parkedLogoutRequests[inResponseTo]; + } } node = rootNode.firstChild; while (node) { @@ -302,11 +315,11 @@ var handleLogoutResponse = function (xml, form, res) { 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')); + if (waiter) waiter.responseReject(new Error(errors.join(', '))); } else { res.send('OK'); - session.samlLogout(null); + if (waiter) waiter.responseResolve(); } }; @@ -378,7 +391,7 @@ var handleLogoutRequest = function (xml, form, res) { }; 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); + logException('Error while assembling the Logout SAML response: ' + err, err); res.send('Failed: ' + err); return; } @@ -422,9 +435,9 @@ http_app.get('/Shibboleth.sso/Login', function (req, res) { 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)); + res.send('Failed: ' + err); return; } res.redirect(svcUrl); // redirect to the IdP @@ -446,51 +459,56 @@ http_app.get('/Shibboleth.sso/Login', function (req, res) { // 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; + waiter, 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; - }); + waiter = waitingLogouts[sid]; + if (!waiter) waitingLogouts[sid] = waiter = makeDoubleWaiter(sid); + + waiter.promise + .then(function (samlData) { + 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) request['samlp:LogoutRequest']['samlp:SessionIndex'] = {'#text': samlData.SessionIndex}; + makeSamlRedirectUrl(SLOServiceURL, 'SAMLRequest', request, null, function (err, svcUrl) { + if (err) { + logException('Error while assembling the Logout SAML request: ' + err, err); + waiter.done = Date.now(); + waiter.responseReject(err instanceof Error ? err : new Error(err)); + res.send('Failed: ' + err); + return; + } + res.redirect(svcUrl); // redirect to the IdP + parkedLogoutRequests[requestId] = waiter; + waiter.parked = Date.now(); + waiter.requestId = requestId; + }); + }) + .catch(function (e) { + logException('Exception while processing login request: ' + e, e); + res.send('Error: ' + e); + if (waiter) { + waiter.done = Date.now(); + waiter.responseReject(e); + } + }) + .done(); }); // user logged in, read the credentials @@ -606,7 +624,7 @@ 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); + logException('Error while deflating request: ' + err, err); res.send('Failed: ' + err); return; } @@ -618,25 +636,57 @@ http_app.get('/Shibboleth.sso/SLO/Redirect', function (req, res) { }); // ================================================================================ -// Web API handler +// Web API handlers // ================================================================================ 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'); + res.json({'code': -1, 'message': 'sid query parameter not set'}); return; } waiter = waitingLogins[sid]; if (!waiter) waitingLogins[sid] = waiter = makeWaiter(sid); waiter.listeners = waiter.listeners + 1; waiter.promise.then(function (jsonResponse) { + delete waitingLogins[sid]; res.json({'code': 0, 'message': 'OK', 'data': jsonResponse}); + }, function (err) { delete waitingLogins[sid]; + res.json({'code': 1, 'message': '' + err}); + }).done(); +}); + +http_app.get('/Shibboleth.sso/WaitLogout', function (req, res) { + var sid = req.query.sid, + saml = req.query.saml, + waiter, samlData; + if (!sid) { + res.json({'code': -1, 'message': 'sid query parameter not set'}); + return; + } + if (!saml) { + res.json({'code': -2, 'message': 'saml query parameter not set'}); + return; + } + try { + samlData = JSON.parse(saml); + } + catch (e) { + res.json({'code': -3, 'message': 'saml query parameter is not proper JSON: ' + e}); + return; + } + + waiter = waitingLogouts[sid]; + if (!waiter) waitingLogouts[sid] = waiter = makeDoubleWaiter(sid); + waiter.listeners = waiter.listeners + 1; + waiter.resolve(samlData); + waiter.responsePromise.then(function () { + delete waitingLogouts[sid]; + res.json({'code': 0, 'message': 'OK'}); }, function (err) { + delete waitingLogouts[sid]; res.json({'code': 1, 'message': '' + err}); - delete waitingLogins[sid]; }).done(); }); diff --git a/server/handlers.py b/server/handlers.py index 5df7161..42f53b4 100644 --- a/server/handlers.py +++ b/server/handlers.py @@ -27,6 +27,12 @@ class CreateSession(CodeqService): request.reply({'code': 0, 'message': 'OK', 'sid': server.user_session.UserSession().get_sid()}) +class DestroySession(CodeqService): + def process(self, request): + request.session.destroy() + request.reply({'code': 0, 'message': 'OK'}) + + class Login(CodeqService): """Logs in a client, authenticating the session. """ @@ -48,9 +54,10 @@ class Login(CodeqService): settings = session.get_settings() request.reply({'code': 0, 'message': 'OK', 'name': name, 'email' : email, 'joined' : date_joined.isoformat(), 'last-login' : last_login.isoformat(), 'settings': settings}) + class Logout(CodeqService): def process(self, request): - request.session.destroy() + request.session.logout() request.reply({'code': 0, 'message': 'OK'}) @@ -302,6 +309,7 @@ class SamlLogin(CodeqService): # maps actions to their handlers incoming_handlers = { 'create_session': CreateSession(), + 'destroy_session': DestroySession(), 'login': Login(), 'signup': Signup(), 'change_password': ChangePassword(), @@ -318,7 +326,8 @@ incoming_handlers = { 'load_problem': LoadProblem(), 'end_problem': EndProblem(), 'user_stat': GetUserStat(), - 'saml_login': SamlLogin() + 'saml_login': SamlLogin(), + 'saml_logout': Logout() } diff --git a/server/user_session.py b/server/user_session.py index 739da9a..dbf886c 100644 --- a/server/user_session.py +++ b/server/user_session.py @@ -154,6 +154,21 @@ class UserSession(object): pass db.return_connection(conn) + def logout(self): + """Logs out the session, rendering it anonymous.""" + with self._access_lock: + lang_session = self._lang_session + self._lang_session = None + uid = self.uid + sid = self.sid + username = self.username + self.uid = None + self.username = None + self.settings = {} + if lang_session is not None: # do not handle the language session holding the lock: we may deadlock if the callee calls the caller + lang_session.destroy() + logging.debug('User session logged out: username={0}, uid={1}, sid={2}'.format(username, uid, sid)) + def destroy(self): """Destroys the session.""" with module_access_lock: diff --git a/web/main.js b/web/main.js index 67d4274..374f2fc 100644 --- a/web/main.js +++ b/web/main.js @@ -2,6 +2,7 @@ var engine = require('engine.io'), // web sockets communication handler, sitting http = require('http'), // HTTP library https = require('https'), // HTTPS library http_server = http.createServer(), // the low-level HTTP handler + querystring = require('querystring'), // encodes and decodes URL query strings net = require('net'), // TCP sockets library Promise = require('bluebird'), // the promises library log4js = require('log4js'), // the logger @@ -9,10 +10,10 @@ var engine = require('engine.io'), // web sockets communication handler, sitting express = require('express'), // library providing the Express web framework http_app = express(); // web framework engine, sitting on the low-level HTTP handler -var SAML_LOGINWAIT_URL = process.env.CODEQ_SAML_LOGINWAIT_URL || 'https://codeq.si/Shibboleth.sso/WaitLogin'; +var SAML_SERVICE_URL = process.env.CODEQ_SAML_SERVICE_URL || 'https://codeq.si/Shibboleth.sso/'; -var samlLoginwaitUrlParsed = url.parse(SAML_LOGINWAIT_URL), - samlLoginwaitIsHttps = samlLoginwaitUrlParsed.protocol === 'https:'; +var samlUrlParsed = url.parse(SAML_SERVICE_URL), + samlIsHttps = samlUrlParsed.protocol === 'https:'; // ================================================================================ // The logger @@ -85,7 +86,7 @@ http_app.get('/ws/logout', function (req, res) { session = sessions[sid]; if (session) { logger.debug('Logging out via AJAX, sid=' + sid); - guiHandlers.logout(session, {'sid': sid, 'action': 'logout'}); + guiHandlers.destroy(session, {'sid': sid, 'action': 'destroy'}); } else if (sid) { logger.warn('Cannot logout via AJAX: no session exists with sid=' + sid); @@ -98,114 +99,141 @@ http_app.get('/ws/logout', function (req, res) { }); -// ================================================================================ -// Web services stuff - handlers for GUI requests, usually just forwarded to Python -// ================================================================================ - -// connected clients -var sessions = { - // sid: sessions description {sid: string, lastActivity: Date.now(), socket: net.Socket} -}; - -// GUI action handlers, keyed by the action name, values are functions that take the session and the message, or truthy values -var guiHandlers = { - // special-case action handlers - 'logout': function actionLogout(session, message) { - // logout, the user quit the app - logger.debug('Logout GUI'); - sendDataToPython(message).finally(function () { - //session.end({'type': 'reset', 'code': 9999, 'message': 'Bye.'}); - session.end({'code': 0, 'message': 'Bye.', 'tid': message.tid, 'sid':message.sid}); - }).done(); - }, - - 'saml_login': function samlLogin(session, message) { - var guiResponse = {'tid': message.tid, 'sid': message.sid}, - aborted = false, +var performSamlAction = function (samlAction, params) { + return new Promise(function (resolve, reject) { + var aborted = false, options = { - hostname: samlLoginwaitUrlParsed.hostname, - port: samlLoginwaitUrlParsed.port || (samlLoginwaitIsHttps ? 443 : 80), - path: samlLoginwaitUrlParsed.pathname + '?sid=' + session.sid, + hostname: samlUrlParsed.hostname, + port: samlUrlParsed.port || (samlIsHttps ? 443 : 80), + path: samlUrlParsed.pathname + samlAction + '?' + querystring.encode(params), method: 'GET', rejectUnauthorized: false // allow server certificates we don't know }, handleResponse = function (res) { var chunks = [], - status = res.statusCode, - headers, i, m, pythonRequest, keys; - if (status !== 200) { - guiResponse.code = -2; - guiResponse.message = 'Received an error from the identity server: ' + status + ' ' + res.statusMessage; - chunks.push(guiResponse.message, '\n'); - headers = res.rawHeaders; - for (i = 0; i < headers.length; i++) chunks.push(headers[i], '\n'); - chunks.push('\n'); - } + headers, m, s; res.on('data', function (d) { chunks.push(d); }); res.on('end', function () { if (aborted) return; // already handled - if (status !== 200) { - logger.error(chunks.join('')); - session.send(guiResponse); + if (res.statusCode !== 200) { + // log error with all headers + headers = res.rawHeaders; + if (headers instanceof Array) m = headers.join('\n') + '\n\n' + chunks.join(''); + else m = chunks.join(''); + s = 'Received an error from the identity server: ' + res.statusCode + ' ' + res.statusMessage; + logger.error(s + '\n' + m); + reject(new Error(s)); return; } try { m = JSON.parse(chunks.join('')); } catch (e) { - guiResponse.code = -3; - guiResponse.message = 'Response from the identity server is not a JSON: ' + e; - logException(guiResponse.message + '\n' + chunks.join(''), e); - session.send(guiResponse); + logException('Response from the identity server is not a JSON: ' + e + '\n' + chunks.join(''), e); + reject(e); return; } - if (m.code !== 0) { - guiResponse.code = m.code || -5; - guiResponse.message = m.message || 'Status message from the identity server is not set'; - session.send(guiResponse); - } - else { - message.saml_data = m.data && m.data.userData; // add the data from SAML authentication, and forward everything to Python - sendDataToPython(message) - .then(function (jsonReply) { - session.samlData = m.data; // perhaps we'll need the SAML data in the future - session.send(jsonReply); - }, function (exc) { - guiResponse.code = -6; - guiResponse.message = 'Python processing of SAML login generated an error: ' + exc; - logException(guiResponse.message, exc); - session.send(guiResponse); - }) - .done(); - } + resolve(m); }); }, req; - if (samlLoginwaitIsHttps) req = https.request(options, handleResponse); + if (samlIsHttps) req = https.request(options, handleResponse); else req = http.request(options, handleResponse); req.on('error', function (e) { if (aborted) return; // already handled - guiResponse.code = -1; - guiResponse.message = 'Error when trying to contact the identity server: ' + e; logException('Error when trying to contact the identity server: ' + e, e); - session.send(guiResponse); + reject(e instanceof Error ? e : new Error('' + e)); }); req.setTimeout(600000, function () { // 10 minutes timeout + var m = 'Timeout waiting for a reply from the identity server'; aborted = true; req.abort(); - guiResponse.code = -4; - guiResponse.message = 'Timeout waiting for a reply from the identity server'; - logger.error(guiResponse.message); - session.send(guiResponse); + logger.error(m); + reject(new Error(m)); }); req.end(); + }); +}; + +// ================================================================================ +// Web services stuff - handlers for GUI requests, usually just forwarded to Python +// ================================================================================ + +// connected clients +var sessions = { + // sid: sessions description {sid: string, lastActivity: Date.now(), socket: net.Socket} +}; + +// GUI action handlers, keyed by the action name, values are functions that take the session and the message, or truthy values +var guiHandlers = { + // special-case action handlers + 'destroy': function actionLogout(session, message) { + // logout and destroy the session, the user quit the app + logger.debug('Destroy GUI'); + sendDataToPython(message).finally(function () { + //session.end({'type': 'reset', 'code': 9999, 'message': 'Bye.'}); + session.end({'code': 0, 'message': 'Bye.', 'tid': message.tid, 'sid':message.sid}); + }).done(); + }, + + 'saml_login': function samlLogin(session, message) { + var samlData = null; + performSamlAction('WaitLogin', {'sid': session.sid}) + .then(function (jsonObj) { + if (jsonObj.code !== 0) return jsonObj; + samlData = jsonObj.data; + if (!samlData) return {'tid': message.tid, 'sid': message.sid, 'code': -2, 'message': 'IdP proxy did not return any SAML data'}; + if (!samlData.userData) return {'tid': message.tid, 'sid': message.sid, 'code': -3, 'message': 'IdP proxy did not return any SAML user data'}; + message.saml_data = samlData.userData; // add the data from SAML authentication, and forward everything to Python + return sendDataToPython(message); + }) + .then(function (jsonObj) { + if (jsonObj.code !== 0) session.samlData = samlData; // we need the SAML data to be able to perform global logout (saml_logout action) + session.send(jsonObj); + }) + .catch(function (e) { + var m = 'Error while trying to process the SAML request using the identity server: ' + e; + logException(m, e); + session.send({'tid': message.tid, 'sid': message.sid, 'code': -1, 'message': m}); + }) + .done(); + }, + + 'saml_logout': function samlLogout(session, message) { + var samlData = session.samlData, + keys, sd, key, i; + if (!samlData) { + session.send({'tid': message.tid, 'sid': message.sid, 'code': -1, 'message': 'The session was not authenticated using SAML'}); + return; + } + // make a copy of SAML data, excluding user data + keys = Object.keys(samlData); + sd = {}; + for (i = keys.length - 1; i >= 0; i--) { + key = keys[i]; + if (key !== 'userData') sd[key] = samlData[key]; + } + + performSamlAction('WaitLogout', {'sid': session.sid, 'saml': JSON.stringify(sd)}) + .then(function (jsonObj) { + if (jsonObj.code !== 0) return jsonObj; + return sendDataToPython(message); + }) + .then(session.send) + .catch(function (e) { + var m = 'Error while trying to process the SAML request using the identity server: ' + e; + logException(m, e); + session.send({'tid': message.tid, 'sid': message.sid, 'code': -2, 'message': m}); + }) + .done(); }, // actions to use default handling should define truthy values that are not functions // (this is to filter out unnecessary traffic before it hits Python) 'login': true, + 'logout': true, 'signup': true, 'change_password': true, 'activity': true, @@ -289,9 +317,23 @@ server.on('connection', function (socket) { s.close(); s = null; }); + setTimeout(function () { // give it a second, then close it forcefully if it's still open + if (s) { + try { + s.close(); + } + catch (e) {} // ignore + s = null; + } + }, 1000); } catch (e) { logException('Failed to send reply to GUI about a fatal error: ' + e, e); + try { + s.close(); + s = null; + } + catch (e) {} // ignore } socket = null; // this socket will no longer be processed session.socket = null; -- cgit v1.2.1