pam: use a pam object method instead of pam module function
[cascardo/ipsilon.git] / ipsilon / login / common.py
1 # Copyright (C) 2013 Ipsilon project Contributors, for license see COPYING
2
3 from ipsilon.util.page import Page
4 from ipsilon.util.user import UserSession
5 from ipsilon.util.plugin import PluginInstaller, PluginLoader
6 from ipsilon.util.plugin import PluginObject
7 from ipsilon.util.config import ConfigHelper
8 from ipsilon.info.common import Info
9 from ipsilon.util.cookies import SecureCookie
10 from ipsilon.util.log import Log
11 import cherrypy
12
13
14 USERNAME_COOKIE = 'ipsilon_default_username'
15
16
17 class LoginHelper(Log):
18
19     """Common code supporing login operations.
20
21     Ipsilon can authtenticate a user by itself via it's own login
22     handlers (classes derived from `LoginManager`) or it can
23     capitalize on the authentication provided by the container Ipsilon
24     is running in (currently WSGI inside Apache). We refer to the
25     later as "external authentication" because it occurs outside of
26     Ipsilon. However in both cases there is a common need to execute
27     the same code irregardless of where the authntication
28     occurred. This class serves that purpose.
29     """
30
31     def get_external_auth_info(self):
32         """Return the username and auth type for external authentication.
33
34         If the container Ipsilon is running inside of has already
35         authenticated the user prior to reaching one of our endpoints
36         return the username and the name of authenticaion method
37         used. In Apache this will be REMOTE_USER and AUTH_TYPE.
38
39         The returned auth_type will be prefixed with the string
40         "external:" to clearly distinguish between the same method
41         being used internally by Ipsilon from the same method used by
42         the container hosting Ipsilon. The returned auth_type string
43         will be lower case.
44
45         If there was no external authentication both username and
46         auth_type will be None. It is possible for a username to be
47         returned without knowing the auth_type.
48
49         :return: tuple of (username, auth_type)
50         """
51
52         auth_type = None
53         username = cherrypy.request.login
54         if username:
55             auth_type = cherrypy.request.wsgi_environ.get('AUTH_TYPE')
56             if auth_type:
57                 auth_type = 'external:%s' % (auth_type.lower())
58
59         self.debug("get_external_auth_info: username=%s auth_type=%s" % (
60             username, auth_type))
61
62         return username, auth_type
63
64     def initialize_login_session(self, username, info=None,
65                                  auth_type=None, userdata=None):
66         """Establish a login session for a user.
67
68         Builds a `UserSession` object and bind attributes associated
69         with the user to the session.
70
71         User attributes derive from two sources, the `Info` object
72         passed as the info parameter and the userdata dict. The `Info`
73         object encapsulates the info plugins run by Ipsilon. The
74         userdata dict is additional information typically derived
75         during authentication.
76
77         The `Info` derived attributes are merged with the userdata
78         attributes to form one set of user attributes. The user
79         attributes are checked for consistenccy. Additional attrbutes
80         may be synthesized and added to the user attributes. The final
81         set of user attributes is then bound to the returned
82         `UserSession` object.
83
84         :param username:  The username bound to the identity principal
85         :param info:      A `Info` object providing user attributes
86         :param auth_type: Authenication method name
87         :param userdata:  Dict of additional user attributes
88
89         :return: `UserSession` object
90         """
91
92         session = UserSession()
93
94         # merge attributes from login plugin and info plugin
95         if info:
96             infoattrs = info.get_user_attrs(username)
97         else:
98             infoattrs = dict()
99
100         if userdata is None:
101             userdata = dict()
102
103         if '_groups' in infoattrs:
104             userdata['_groups'] = list(set(userdata.get('_groups', []) +
105                                            infoattrs['_groups']))
106             del infoattrs['_groups']
107
108         if '_extras' in infoattrs:
109             userdata['_extras'] = userdata.get('_extras', {})
110             userdata['_extras'].update(infoattrs['_extras'])
111             del infoattrs['_extras']
112
113         userdata.update(infoattrs)
114
115         self.debug("User %s attributes: %s" % (username, repr(userdata)))
116
117         if auth_type:
118             if userdata:
119                 userdata.update({'_auth_type': auth_type})
120             else:
121                 userdata = {'_auth_type': auth_type}
122
123         # create session login including all the userdata just gathered
124         session.login(username, userdata)
125
126         return session
127
128
129 class LoginManagerBase(ConfigHelper, PluginObject, LoginHelper):
130
131     def __init__(self, *args):
132         ConfigHelper.__init__(self)
133         PluginObject.__init__(self, *args)
134         self._root = None
135         self._site = None
136         self.path = '/'
137         self.info = None
138
139     def redirect_to_path(self, path, trans=None):
140         base = cherrypy.config.get('base.mount', "")
141         url = '%s/login/%s' % (base, path)
142         if trans:
143             url += '?%s' % trans.get_GET_arg()
144         raise cherrypy.HTTPRedirect(url)
145
146     def auth_successful(self, trans, username, auth_type=None, userdata=None):
147         self.initialize_login_session(username, self.info, auth_type, userdata)
148
149         # save username into a cookie if parent was form base auth
150         if auth_type == 'password':
151             cookie = SecureCookie(USERNAME_COOKIE, username)
152             # 15 days
153             cookie.maxage = 1296000
154             cookie.send()
155
156         transdata = trans.retrieve()
157         self.debug(transdata)
158         redirect = transdata.get('login_return',
159                                  cherrypy.config.get('base.mount', "") + '/')
160         self.debug('Redirecting back to: %s' % redirect)
161
162         # on direct login the UI (ie not redirected by a provider) we ned to
163         # remove the transaction cookie as it won't be needed anymore
164         if trans.provider == 'login':
165             self.debug('Wiping transaction data')
166             trans.wipe()
167         raise cherrypy.HTTPRedirect(redirect)
168
169     def auth_failed(self, trans, message=None):
170         # try with next module
171         next_login = self.next_login()
172         if next_login:
173             return self.redirect_to_path(next_login.path, trans)
174
175         # return to the caller if any
176         session = UserSession()
177
178         transdata = trans.retrieve()
179
180         # on direct login the UI (ie not redirected by a provider) we ned to
181         # remove the transaction cookie as it won't be needed anymore
182         if trans.provider == 'login':
183             trans.wipe()
184
185         # destroy session and return error
186         if 'login_return' not in transdata:
187             session.logout(None)
188             raise cherrypy.HTTPError(401, message)
189
190         raise cherrypy.HTTPRedirect(transdata['login_return'])
191
192     def set_auth_error(self):
193         cherrypy.response.status = 401
194
195     def get_tree(self, site):
196         raise NotImplementedError
197
198     def register(self, root, site):
199         self._root = root
200         self._site = site
201
202     def next_login(self):
203         plugins = self._site[FACILITY]
204         try:
205             idx = plugins.enabled.index(self.name)
206             item = plugins.enabled[idx + 1]
207             return plugins.available[item]
208         except (ValueError, IndexError):
209             return None
210
211     def other_login_stacks(self):
212         plugins = self._site[FACILITY]
213         stack = list()
214         try:
215             idx = plugins.enabled.index(self.name)
216         except (ValueError, IndexError):
217             idx = None
218         for i in range(0, len(plugins.enabled)):
219             if i == idx:
220                 continue
221             stack.append(plugins.available[plugins.enabled[i]])
222         return stack
223
224     def on_enable(self):
225
226         # and add self to the root
227         self._root.add_subtree(self.name, self.get_tree(self._site))
228
229         # Get handle of the info plugin
230         self.info = self._root.info
231
232
233 class LoginPageBase(Page):
234
235     def __init__(self, site, mgr):
236         super(LoginPageBase, self).__init__(site)
237         self.lm = mgr
238         self._Transaction = None
239
240     def root(self, *args, **kwargs):
241         raise cherrypy.HTTPError(500)
242
243
244 class LoginFormBase(LoginPageBase):
245
246     def __init__(self, site, mgr, page, template=None):
247         super(LoginFormBase, self).__init__(site, mgr)
248         self.formpage = page
249         self.formtemplate = template or 'login/form.html'
250         self.trans = None
251
252     def GET(self, *args, **kwargs):
253         context = self.create_tmpl_context()
254         return self._template(self.formtemplate, **context)
255
256     def root(self, *args, **kwargs):
257         self.trans = self.get_valid_transaction('login', **kwargs)
258         op = getattr(self, cherrypy.request.method, self.GET)
259         if callable(op):
260             return op(*args, **kwargs)
261
262     def create_tmpl_context(self, **kwargs):
263         other_stacks = None
264         other_login_stacks = self.lm.other_login_stacks()
265         if other_login_stacks:
266             other_stacks = list()
267             for ls in other_login_stacks:
268                 url = '%s/login/%s?%s' % (
269                     self.basepath, ls.path, self.trans.get_GET_arg()
270                 )
271                 name = ls.name
272                 other_stacks.append({'url': url, 'name': name})
273
274         cookie = SecureCookie(USERNAME_COOKIE)
275         cookie.receive()
276         username = cookie.value
277
278         target = None
279         if self.trans is not None:
280             tid = self.trans.transaction_id
281             target = self.trans.retrieve().get('login_target')
282             username = self.trans.retrieve().get('login_username')
283         if tid is None:
284             tid = ''
285
286         if username is None:
287             username = ''
288
289         context = {
290             "title": 'Login',
291             "action": '%s/%s' % (self.basepath, self.formpage),
292             "service_name": self.lm.service_name,
293             "username_text": self.lm.username_text,
294             "password_text": self.lm.password_text,
295             "description": self.lm.help_text,
296             "other_stacks": other_stacks,
297             "username": username,
298             "login_target": target,
299             "cancel_url": '%s/login/cancel?%s' % (self.basepath,
300                                                   self.trans.get_GET_arg()),
301         }
302         context.update(kwargs)
303         if self.trans is not None:
304             t = self.trans.get_POST_tuple()
305             context.update({t[0]: t[1]})
306
307         return context
308
309
310 FACILITY = 'login_config'
311
312
313 class Login(Page):
314
315     def __init__(self, *args, **kwargs):
316         super(Login, self).__init__(*args, **kwargs)
317         self.cancel = Cancel(*args, **kwargs)
318         self.info = Info(self._site)
319
320         plugins = PluginLoader(Login, FACILITY, 'LoginManager')
321         plugins.get_plugin_data()
322         self._site[FACILITY] = plugins
323
324         available = plugins.available.keys()
325         self.debug('Available login managers: %s' % str(available))
326
327         for item in plugins.available:
328             plugin = plugins.available[item]
329             plugin.register(self, self._site)
330
331         for item in plugins.enabled:
332             self.debug('Login plugin in enabled list: %s' % item)
333             if item not in plugins.available:
334                 continue
335             plugins.available[item].enable()
336
337     def add_subtree(self, name, page):
338         self.__dict__[name] = page
339
340     def get_first_login(self):
341         plugin = None
342         plugins = self._site[FACILITY]
343         if plugins.enabled:
344             first = plugins.enabled[0]
345             plugin = plugins.available[first]
346         return plugin
347
348     def root(self, *args, **kwargs):
349         plugin = self.get_first_login()
350         if plugin:
351             trans = self.get_valid_transaction('login', **kwargs)
352             redirect = '%s/login/%s?%s' % (self.basepath,
353                                            plugin.path,
354                                            trans.get_GET_arg())
355             raise cherrypy.HTTPRedirect(redirect)
356         return self._template('login/index.html', title='Login')
357
358
359 class Logout(Page):
360     def __init__(self, *args, **kwargs):
361         super(Logout, self).__init__(*args, **kwargs)
362         self.handlers = {}
363
364     def root(self, *args, **kwargs):
365         us = UserSession()
366
367         for provider in self.handlers:
368             self.debug("Calling logout for provider %s" % provider)
369             obj = self.handlers[provider]
370             obj()
371
372         us.logout(self.user)
373         return self._template('logout.html', title='Logout')
374
375     def add_handler(self, provider, handler):
376         """
377         Providers can register a logout handler here that is called
378         when the IdP logout link is accessed.
379         """
380         self.handlers[provider] = handler
381
382
383 class Cancel(Page):
384
385     def GET(self, *args, **kwargs):
386
387         session = UserSession()
388         session.logout(None)
389
390         # return to the caller if any
391         transdata = self.get_valid_transaction('login', **kwargs).retrieve()
392         if 'login_return' not in transdata:
393             raise cherrypy.HTTPError(401)
394         raise cherrypy.HTTPRedirect(transdata['login_return'])
395
396     def root(self, *args, **kwargs):
397         op = getattr(self, cherrypy.request.method, self.GET)
398         if callable(op):
399             return op(*args, **kwargs)
400
401
402 class LoginManagerInstaller(object):
403     def __init__(self):
404         self.facility = FACILITY
405         self.ptype = 'login'
406         self.name = None
407
408     def unconfigure(self, opts, changes):
409         return
410
411     def install_args(self, group):
412         raise NotImplementedError
413
414     def validate_args(self, args):
415         return
416
417     def configure(self, opts, changes):
418         raise NotImplementedError
419
420
421 class LoginMgrsInstall(object):
422
423     def __init__(self):
424         pi = PluginInstaller(LoginMgrsInstall, FACILITY)
425         self.plugins = pi.get_plugins()