summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAleš Smodiš <aless@guru.si>2015-10-15 18:46:54 +0200
committerAleš Smodiš <aless@guru.si>2015-10-15 18:46:54 +0200
commit4a781b21db10f82e35b9945109b5f4d41ad0e8c3 (patch)
tree3907cb657aeb9bf4ba27dcc630935329bac1a5b6
parentde2ea4c96a007cd1c6545f0b4a063d3392a1a0d3 (diff)
Server-side support for SAML logout, sessions are destroyed only using an AJAX call.
-rw-r--r--saml/saml.js156
-rw-r--r--server/handlers.py13
-rw-r--r--server/user_session.py15
-rw-r--r--web/main.js192
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;