Add support for logout over SOAP
[cascardo/ipsilon.git] / ipsilon / providers / saml2 / sessions.py
1 # Copyright (C) 2015 Ipsilon project Contributors, for license see COPYING
2
3 from cherrypy import config as cherrypy_config
4 from ipsilon.util.log import Log
5 from ipsilon.util.data import SAML2SessionStore
6 import datetime
7 from lasso import (
8     SAML2_METADATA_BINDING_SOAP,
9     SAML2_METADATA_BINDING_REDIRECT,
10 )
11
12 LOGGED_IN = 1
13 INIT_LOGOUT = 2
14 LOGGING_OUT = 4
15 LOGGED_OUT = 8
16
17
18 class SAMLSession(Log):
19     """
20     A SAML login session.
21
22        uuidval - Unique ID stored in the database
23        session_id - ID of the login session
24        provider_id - ID of the SP
25        user - the login name of the user that owns the session
26        login_session - the Login session object
27        logoutstate - an integer constant representing where in the
28                      logout process this request is
29        relaystate - where the user will be redirected when logout is
30                     complete
31        request_id - the logout request ID if initiated from IdP. The
32                     logout response will include an InResponseTo value
33                     which matches this.
34        logout_request - the Logout request object
35        expiration_time - the time the login session expires
36        supported_logout_mechs - logout mechanisms supported by this session
37     """
38     def __init__(self, uuidval, session_id, provider_id, user,
39                  login_session, logoutstate=None, relaystate=None,
40                  logout_request=None, request_id=None,
41                  expiration_time=None,
42                  supported_logout_mechs=None):
43
44         self.uuidval = uuidval
45         self.session_id = session_id
46         self.provider_id = provider_id
47         self.user = user
48         self.login_session = login_session
49         self.logoutstate = logoutstate
50         self.relaystate = relaystate
51         self.request_id = request_id
52         self.logout_request = logout_request
53         self.expiration_time = expiration_time
54         if supported_logout_mechs is None:
55             supported_logout_mechs = []
56         self.supported_logout_mechs = supported_logout_mechs
57
58     def set_logoutstate(self, relaystate=None, request=None, request_id=None):
59         """
60         Update attributes needed to determine the state of the session for
61         logout.
62
63         The database is not updated when these are set. It is expected that
64         this is called prior to start_logout()
65         """
66         if relaystate:
67             self.relaystate = relaystate
68         if request:
69             self.logout_request = request
70         if request_id:
71             self.request_id = request_id
72
73     def dump(self):
74         self.debug('session_id %s' % self.session_id)
75         self.debug('provider_id %s' % self.provider_id)
76         self.debug('login session %s' % self.login_session)
77         self.debug('logoutstate %s' % self.logoutstate)
78         self.debug('logout mech %s' % self.supported_logout_mechs)
79
80     def convert(self):
81         """
82         Convert this object into something suitable to store in the
83         data backend.
84         """
85         data = dict()
86         data['session_id'] = self.session_id
87         data['provider_id'] = self.provider_id
88         data['user'] = self.user
89         data['login_session'] = self.login_session
90         data['logoutstate'] = self.logoutstate
91         data['relaystate'] = self.relaystate
92         data['logout_request'] = self.logout_request
93         data['request_id'] = self.request_id
94         data['expiration_time'] = self.expiration_time
95
96         return {self.uuidval: data}
97
98
99 class SAMLSessionFactory(Log):
100     """
101     Access SAML session information.
102
103     The sessions are stored via the data backend.
104
105     When a user logs in, add_session() is called and a new SAMLSession
106     created and added to the table.
107
108     When a user logs out, the next login session is found and moved to
109     sessions_logging_out. remove_session() will look in both when trying
110     to remove a session.
111
112     Returns a SAMLSession object representing the new session.
113     """
114     def __init__(self, database_url):
115         self._ss = SAML2SessionStore(database_url=database_url)
116         self.user = None
117
118     def _data_to_samlsession(self, uuidval, data):
119         """
120         Convert data from the data backend to a SAMLSession object.
121         """
122         return SAMLSession(uuidval,
123                            data.get('session_id'),
124                            data.get('provider_id'),
125                            data.get('user'),
126                            data.get('login_session'),
127                            data.get('logoutstate'),
128                            data.get('relaystate'),
129                            data.get('logout_request'),
130                            data.get('request_id'),
131                            data.get('expiration_time'),
132                            data.get('supported_logout_mechs'))
133
134     def add_session(self, session_id, provider_id, user, login_session,
135                     request_id, supported_logout_mechs):
136         """
137         Add a new login session to the table.
138
139         :param session_id: The login session ID
140         :param provider_id: The URL of the SP
141         :param user: The NameID username
142         :param login_session: The lasso Login session
143         :param request_id: The request ID of the Logout
144         :param supported_logout_mechs: A list of logout protocols supported
145         """
146         self.user = user
147
148         timeout = cherrypy_config['tools.sessions.timeout']
149         t = datetime.timedelta(seconds=timeout * 60)
150         expiration_time = datetime.datetime.now() + t
151
152         data = {'session_id': session_id,
153                 'provider_id': provider_id,
154                 'user': user,
155                 'login_session': login_session,
156                 'logoutstate': LOGGED_IN,
157                 'expiration_time': expiration_time,
158                 'request_id': request_id,
159                 'supported_logout_mechs': supported_logout_mechs}
160
161         uuidval = self._ss.new_session(data)
162
163         return SAMLSession(uuidval, session_id, provider_id, user,
164                            login_session, LOGGED_IN,
165                            request_id=request_id,
166                            expiration_time=expiration_time)
167
168     def get_session_by_id(self, session_id):
169         """
170         Retrieve a session by session ID
171         """
172         uuidval, data = self._ss.get_session(session_id=session_id)
173         if uuidval is None:
174             return None
175
176         return self._data_to_samlsession(uuidval, data)
177
178     def get_session_id_by_provider_id(self, provider_id):
179         """
180         Return a tuple of logged-in session IDs by provider_id
181         """
182         candidates = self._ss.get_user_sessions(self.user)
183
184         session_ids = []
185         for c in candidates:
186             key = c.keys()[0]
187             if c[key].get('provider_id') == provider_id:
188                 samlsession = self._data_to_samlsession(key, c[key])
189                 session_ids.append(samlsession.session_id.encode('utf-8'))
190
191         return tuple(session_ids)
192
193     def get_session_by_request_id(self, request_id):
194         """
195         Retrieve a session by logout request ID
196         """
197         uuidval, data = self._ss.get_session(request_id=request_id)
198         if uuidval is None:
199             return None
200
201         return self._data_to_samlsession(uuidval, data)
202
203     def remove_session(self, samlsession):
204         return self._ss.remove_session(samlsession.uuidval)
205
206     def remove_session_by_session_id(self, session_id):
207         session = self.get_session_by_id(session_id)
208         return self._ss.remove_session(session.uuidval)
209
210     def start_logout(self, samlsession, relaystate=None, initial=True):
211         """
212         Move a session into the logging_out state
213
214         samlsession: the SAMLSession object to start logging out
215         relaystate: URL to redirect user to when logout is completed
216         initial: boolean to indicate if this session started logout.
217                  Only the initial session's relaystate is used.
218
219         No return value
220         """
221         if initial:
222             samlsession.logoutstate = INIT_LOGOUT
223         else:
224             samlsession.logoutstate = LOGGING_OUT
225         if relaystate:
226             samlsession.relaystate = relaystate
227         datum = samlsession.convert()
228         self._ss.update_session(datum)
229
230     def get_next_logout(self, peek=False,
231                         logout_mechs=None):
232         """
233         Get the next session in the logged-in state and move
234         it to the logging_out state.  Return the session that is
235         found.
236
237         :param peek: for IdP-initiated logout we can't remove the
238                      session otherwise when the request comes back
239                      in the user won't be seen as being logged-on.
240         :param logout_mechs: An ordered list of logout mechanisms
241                      you're looking for. For each mechanism in order
242                      loop through all sessions. If If no sessions of
243                      this method are available then try the next mechanism
244                      until exhausted. In that case None is returned.
245
246         Returns a tuple of (mechanism, session) or
247         (None, None) if no more sessions in LOGGED_IN state.
248         """
249         candidates = self._ss.get_user_sessions(self.user)
250         if logout_mechs is None:
251             logout_mechs = [SAML2_METADATA_BINDING_REDIRECT, ]
252
253         for mech in logout_mechs:
254             for c in candidates:
255                 key = c.keys()[0]
256                 if ((int(c[key].get('logoutstate', 0)) == LOGGED_IN) and
257                         (mech in c[key].get('supported_logout_mechs'))):
258                     samlsession = self._data_to_samlsession(key, c[key])
259                     self.start_logout(samlsession, initial=False)
260                     return (mech, samlsession)
261         return (None, None)
262
263     def get_initial_logout(self):
264         """
265         Get the initial logout request.
266
267         Raises ValueError if no sessions in INIT_LOGOUT state.
268         """
269         candidates = self._ss.get_user_sessions(self.user)
270
271         # FIXME: what does it mean if there are multiple in init? We
272         #        just return the first one for now. How do we know
273         #        it's the "right" one if multiple logouts are started
274         #        at the same time from different SPs?
275         for c in candidates:
276             key = c.keys()[0]
277             if int(c[key].get('logoutstate', 0)) == INIT_LOGOUT:
278                 samlsession = self._data_to_samlsession(key, c[key])
279                 return samlsession
280         raise ValueError()
281
282     def wipe_data(self):
283         self._ss.wipe_data()
284
285     def dump(self):
286         """
287         Dump all sessions to debug log
288         """
289         candidates = self._ss.get_user_sessions(self.user)
290
291         count = 0
292         for c in candidates:
293             key = c.keys()[0]
294             samlsession = self._data_to_samlsession(key, c[key])
295             self.debug('session %d: %s' % (count, samlsession.convert()))
296             count += 1
297
298 if __name__ == '__main__':
299     provider1 = "http://127.0.0.10/saml2"
300     provider2 = "http://127.0.0.11/saml2"
301
302     # temporary values to simulate cherrypy
303     cherrypy_config['tools.sessions.timeout'] = 60
304
305     factory = SAMLSessionFactory('/tmp/saml2sessions.sqlite')
306     factory.wipe_data()
307
308     sess1 = factory.add_session('_123456', provider1, "admin",
309                                 "<Login/>", '_1234',
310                                 [SAML2_METADATA_BINDING_REDIRECT])
311     sess2 = factory.add_session('_789012', provider2, "testuser",
312                                 "<Login/>", '_7890',
313                                 [SAML2_METADATA_BINDING_SOAP,
314                                  SAML2_METADATA_BINDING_REDIRECT])
315
316     # Test finding sessions by provider
317     ids = factory.get_session_id_by_provider_id(provider2)
318     assert(len(ids) == 1)
319
320     sess3 = factory.add_session('_345678', provider2, "testuser",
321                                 "<Login/>", '_3456',
322                                 [SAML2_METADATA_BINDING_REDIRECT])
323     ids = factory.get_session_id_by_provider_id(provider2)
324     assert(len(ids) == 2)
325
326     # Test finding sessions by session ID
327     test1 = factory.get_session_by_id('_123456')
328     assert(test1.user == 'admin')
329     assert(test1.provider_id == provider1)
330
331     # Log out and remove the first session
332     test1.set_logoutstate('http://www.example.com/idp')
333     factory.start_logout(test1, initial=True)
334     test1 = factory.get_session_by_id('_123456')
335     assert(test1.relaystate == 'http://www.example.com/idp')
336
337     factory.remove_session_by_session_id('_123456')
338
339     # Make sure it is gone from the db
340     test1 = factory.get_session_by_id('_123456')
341     assert(test1 is None)
342
343     test2 = factory.get_session_by_id('_789012')
344     factory.start_logout(test2, initial=True)
345
346     (lmech, test3) = factory.get_next_logout()
347     assert(test3.session_id == '_345678')
348
349     test4 = factory.get_initial_logout()
350     assert(test4.session_id == '_789012')
351
352     # Even though we've started logout, make sure we can still find
353     # all sessions for a provider.
354     ids = factory.get_session_id_by_provider_id(provider2)
355     assert(len(ids) == 2)