374e885bf2991c0991ac98089df19fd8e868352a
[cascardo/ipsilon.git] / ipsilon / providers / saml2 / logout.py
1 # Copyright (C) 2015 Ipsilon project Contributors, for license see COPYING
2
3 from ipsilon.providers.common import ProviderPageBase
4 from ipsilon.providers.common import InvalidRequest
5 from ipsilon.providers.saml2.auth import UnknownProvider
6 from ipsilon.util.user import UserSession
7 from ipsilon.util.constants import SOAP_MEDIA_TYPE
8 import cherrypy
9 import lasso
10 import requests
11
12
13 class LogoutRequest(ProviderPageBase):
14     """
15     SP-initiated logout.
16
17     The sequence is:
18       - On each logout a new session is created to represent that
19         provider
20       - Initial logout request is verified and stored in the login
21         session
22       - If there are other sessions then one is chosen that is not
23         the current provider and a logoutRequest is sent
24       - When a logoutResponse is received the session is removed
25       - When all other sessions but the initial one have been
26         logged out then it a final logoutResponse is sent and the
27         session removed. At this point the cherrypy session is
28         deleted.
29     """
30
31     def __init__(self, *args, **kwargs):
32         super(LogoutRequest, self).__init__(*args, **kwargs)
33
34     def _handle_logout_request(self, us, logout, saml_sessions, message):
35         self.debug('Logout request')
36
37         try:
38             logout.processRequestMsg(message)
39         except (lasso.ServerProviderNotFoundError,
40                 lasso.ProfileUnknownProviderError) as e:
41             msg = 'Invalid SP [%s] (%r [%r])' % (logout.remoteProviderId,
42                                                  e, message)
43             self.error(msg)
44             raise UnknownProvider(msg)
45         except (lasso.ProfileInvalidProtocolprofileError,
46                 lasso.DsError), e:
47             msg = 'Invalid SAML Request: %r (%r [%r])' % (logout.request,
48                                                           e, message)
49             self.error(msg)
50             raise InvalidRequest(msg)
51         except lasso.Error, e:
52             self.error('SLO unknown error: %s' % message)
53             raise cherrypy.HTTPError(400, 'Invalid logout request')
54
55         session_indexes = logout.request.sessionIndexes
56         self.debug('SLO from %s with %s sessions' %
57                    (logout.remoteProviderId, session_indexes))
58
59         # Find the first session being asked to log out. Later we loop over
60         # all the session indexes and mark them as logging out but only one
61         # is needed to handle the request.
62         if len(session_indexes) < 1:
63             self.error('SLO empty session Indexes')
64             raise cherrypy.HTTPError(400, 'Invalid logout request')
65         session = saml_sessions.get_session_by_id(session_indexes[0])
66         if session:
67             try:
68                 logout.setSessionFromDump(session.login_session)
69             except lasso.ProfileBadSessionDumpError as e:
70                 self.error('loading session failed: %s' % e)
71                 raise cherrypy.HTTPError(400, 'Invalid logout session')
72         else:
73             return self._not_logged_in(logout, message)
74
75         try:
76             logout.validateRequest()
77         except lasso.ProfileSessionNotFoundError, e:
78             self.error('Logout failed. No sessions for %s' %
79                        logout.remoteProviderId)
80             return self._not_logged_in(logout, message)
81         except lasso.LogoutUnsupportedProfileError:
82             self.error('Logout failed. Unsupported profile %s' %
83                        logout.remoteProviderId)
84             raise cherrypy.HTTPError(400, 'Profile does not support logout')
85         except lasso.Error, e:
86             self.error('SLO validation failed: %s' % e)
87             raise cherrypy.HTTPError(400, 'Failed to validate logout request')
88
89         try:
90             logout.buildResponseMsg()
91         except lasso.ProfileUnsupportedProfileError:
92             self.error('Unsupported profile for %s' % logout.remoteProviderId)
93             raise cherrypy.HTTPError(400, 'Profile does not support logout')
94         except lasso.Error, e:
95             self.error('SLO failed to build logout response: %s' % e)
96
97         for ind in session_indexes:
98             session = saml_sessions.get_session_by_id(ind)
99             if session:
100                 session.set_logoutstate(relaystate=logout.msgUrl,
101                                         request=message)
102                 saml_sessions.start_logout(session)
103             else:
104                 self.error('SLO request to log out non-existent session: %s' %
105                            ind)
106
107         return
108
109     def _handle_logout_response(self, us, logout, saml_sessions, message,
110                                 samlresponse):
111
112         self.debug('Logout response')
113
114         try:
115             logout.processResponseMsg(message)
116         except getattr(lasso, 'ProfileRequestDeniedError',
117                        lasso.LogoutRequestDeniedError):
118             self.error('Logout request denied by %s' %
119                        logout.remoteProviderId)
120             # Fall through to next provider
121         except (lasso.ProfileInvalidMsgError,
122                 lasso.LogoutPartialLogoutError) as e:
123             self.error('Logout request from %s failed: %s' %
124                        (logout.remoteProviderId, e))
125         else:
126             self.debug('Processing SLO Response from %s' %
127                        logout.remoteProviderId)
128
129             self.debug('SLO response to request id %s' %
130                        logout.response.inResponseTo)
131
132             session = saml_sessions.get_session_by_request_id(
133                 logout.response.inResponseTo)
134
135             if session is not None:
136                 self.debug('Logout response session logout id is: %s' %
137                            session.session_id)
138                 saml_sessions.remove_session(session)
139                 user = us.get_user()
140                 self._audit('Logged out user: %s [%s] from %s' %
141                             (user.name, user.fullname,
142                              logout.remoteProviderId))
143             else:
144                 return self._not_logged_in(logout, message)
145
146         return
147
148     def _not_logged_in(self, logout, message):
149         """
150         The user requested a logout but isn't logged in, or we can't
151         find a session for the user. Try to be nice and redirect them
152         back to the RelayState in the logout request.
153
154         We are only nice in the case of a valid logout request. If the
155         request is invalid (not signed, unknown SP, etc) then an
156         exception is raised.
157         """
158         self.error('Logout attempt without being logged in.')
159
160         if logout.msgRelayState is not None:
161             raise cherrypy.HTTPRedirect(logout.msgRelayState)
162
163         try:
164             logout.processRequestMsg(message)
165         except (lasso.ServerProviderNotFoundError,
166                 lasso.ProfileUnknownProviderError) as e:
167             msg = 'Invalid SP [%s] (%r [%r])' % (logout.remoteProviderId,
168                                                  e, message)
169             self.error(msg)
170             raise UnknownProvider(msg)
171         except (lasso.ProfileInvalidProtocolprofileError,
172                 lasso.DsError), e:
173             msg = 'Invalid SAML Request: %r (%r [%r])' % (logout.request,
174                                                           e, message)
175             self.error(msg)
176             raise InvalidRequest(msg)
177         except lasso.Error, e:
178             self.error('SLO unknown error: %s' % message)
179             raise cherrypy.HTTPError(400, 'Invalid logout request')
180
181         if logout.msgRelayState:
182             raise cherrypy.HTTPRedirect(logout.msgRelayState)
183         else:
184             raise cherrypy.HTTPError(400, 'Not logged in')
185
186     def _soap_logout(self, logout):
187         """
188         Send a SOAP logout request over HTTP and return the result.
189         """
190         headers = {'Content-Type': SOAP_MEDIA_TYPE}
191         try:
192             response = requests.post(logout.msgUrl, data=logout.msgBody,
193                                      headers=headers)
194         except Exception as e:  # pylint: disable=broad-except
195             self.error('SOAP HTTP request failed: (%s) (on %s)' %
196                        (e, logout.msgUrl))
197             raise
198
199         if response.status_code != 200:
200             self.error('SOAP error (%s) (on %s)' %
201                        (response.status, logout.msgUrl))
202             raise InvalidRequest('SOAP HTTP error code', response.status_code)
203
204         if not response.text:
205             self.error('Empty SOAP response')
206             raise InvalidRequest('No content in SOAP response')
207
208         return response.text
209
210     def logout(self, message, relaystate=None, samlresponse=None):
211         """
212         Handle HTTP logout. The supported logout methods are stored
213         in each session. First all the SOAP sessions are logged out
214         then the HTTP Redirect method is used for any remaining
215         sessions.
216
217         The basic process is this:
218          1. A logout request is received. It is processed and the response
219             cached.
220          2. If any other SP's have also logged in as this user then the
221             first such session is popped off and a logout request is
222             generated and forwarded to the SP.
223          3. If a logout response is received then the user is marked
224             as logged out from that SP.
225          Repeat steps 2-3 until only the initial logout request is
226          left unhandled, at which time the pre-generated response is sent
227          back to the SP that originated the logout request.
228
229         The final logout response is always a redirect.
230         """
231         logout = self.cfg.idp.get_logout_handler()
232
233         us = UserSession()
234
235         saml_sessions = self.cfg.idp.sessionfactory
236
237         if lasso.SAML2_FIELD_REQUEST in message:
238             self._handle_logout_request(us, logout, saml_sessions, message)
239         elif samlresponse:
240             self._handle_logout_response(us, logout, saml_sessions, message,
241                                          samlresponse)
242         else:
243             raise cherrypy.HTTPRedirect(400, 'Bad Request. Not a logout ' +
244                                         'request or response.')
245
246         # Fall through to handle any remaining sessions.
247
248         # Find the next SP to logout and send a LogoutRequest
249         logout_order = [
250             lasso.SAML2_METADATA_BINDING_SOAP,
251             lasso.SAML2_METADATA_BINDING_REDIRECT,
252         ]
253         (logout_mech, session) = saml_sessions.get_next_logout(
254             logout_mechs=logout_order)
255         while session:
256             self.debug('Going to log out %s' % session.provider_id)
257
258             try:
259                 logout.setSessionFromDump(session.login_session)
260             except lasso.ProfileBadSessionDumpError as e:
261                 self.error('Failed to load session: %s' % e)
262                 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
263                                             % e)
264             if logout_mech == lasso.SAML2_METADATA_BINDING_REDIRECT:
265                 logout.initRequest(session.provider_id,
266                                    lasso.HTTP_METHOD_REDIRECT)
267             else:
268                 logout.initRequest(session.provider_id,
269                                    lasso.HTTP_METHOD_SOAP)
270
271             try:
272                 logout.buildRequestMsg()
273             except lasso.Error, e:
274                 self.error('failure to build logout request msg: %s' % e)
275                 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
276                                             % e)
277
278             # Set the full list of session indexes for this provider to
279             # log out
280             self.debug('logging out provider id %s' % session.provider_id)
281             indexes = saml_sessions.get_session_id_by_provider_id(
282                 session.provider_id
283             )
284             self.debug('Requesting logout for sessions %s' % (indexes,))
285             req = logout.get_request()
286             req.setSessionIndexes(indexes)
287
288             session.set_logoutstate(relaystate=logout.msgUrl,
289                                     request_id=logout.request.id)
290             saml_sessions.start_logout(session, initial=False)
291
292             self.debug('Request logout ID %s for session ID %s' %
293                        (logout.request.id, session.session_id))
294
295             if logout_mech == lasso.SAML2_METADATA_BINDING_REDIRECT:
296                 self.debug('Redirecting to another SP to logout on %s at %s' %
297                            (logout.remoteProviderId, logout.msgUrl))
298                 raise cherrypy.HTTPRedirect(logout.msgUrl)
299             else:
300                 self.debug('SOAP request to another SP to logout on %s at %s' %
301                            (logout.remoteProviderId, logout.msgUrl))
302                 if logout.msgBody:
303                     message = self._soap_logout(logout)
304                     try:
305                         self._handle_logout_response(us,
306                                                      logout,
307                                                      saml_sessions,
308                                                      message,
309                                                      samlresponse)
310                     except Exception as e:  # pylint: disable=broad-except
311                         self.error('SOAP SLO failed %s' % e)
312                 else:
313                     self.error('Provider does not support SOAP')
314
315             (logout_mech, session) = saml_sessions.get_next_logout(
316                 logout_mechs=logout_order)
317
318         # done while
319
320         # All sessions should be logged out now. Respond to the
321         # original request using the response we cached earlier.
322
323         try:
324             session = saml_sessions.get_initial_logout()
325         except ValueError:
326             self.debug('SLO get_last_session() unable to find last session')
327             raise cherrypy.HTTPError(400, 'Unable to determine logout state')
328
329         redirect = session.relaystate
330         if not redirect:
331             redirect = self.basepath
332
333         saml_sessions.remove_session(session)
334
335         # Log out of cherrypy session
336         user = us.get_user()
337         self._audit('Logged out user: %s [%s] from %s' %
338                     (user.name, user.fullname,
339                      session.provider_id))
340         us.logout(user)
341
342         self.debug('SLO redirect to %s' % redirect)
343
344         raise cherrypy.HTTPRedirect(redirect)