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