Coverage for gramex\handlers\authhandler.py : 66%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import os
2import csv
3import six
4import json
5import time
6import uuid
7import logging
8import tornado.escape
9import tornado.httpclient
10from tornado.auth import GoogleOAuth2Mixin
11from tornado.gen import coroutine, sleep
12from tornado.web import HTTPError
13from collections import Counter
14from orderedattrdict import AttrDict
15import gramex
16import gramex.cache
17from gramex.http import UNAUTHORIZED, FORBIDDEN
18from gramex.config import check_old_certs, app_log, objectpath, str_utf8
19from gramex.transforms import build_transform
20from .basehandler import BaseHandler, build_log_info
22_folder = os.path.dirname(os.path.abspath(__file__))
23_auth_template = os.path.join(_folder, 'auth.template.html')
24_user_info_path = os.path.join(gramex.variables.GRAMEXDATA, 'auth.user.db')
25_user_info = gramex.cache.SQLiteStore(_user_info_path, table='user')
27# Python 3 csv.writer.writerow writes as str(), which is unicode in Py3.
28# Python 2 csv.writer.writerow writes as str(), which is bytes in Py2.
29# So we use cStringIO.StringIO in Py2 (which handles bytes).
30# Since Py3 doesn't have cStringIO, we use io.StringIO (which handles unicode)
31try:
32 import cStringIO
33 StringIOClass = cStringIO.StringIO
34except ImportError:
35 import io
36 StringIOClass = io.StringIO
39class AuthHandler(BaseHandler):
40 '''The parent handler for all Auth handlers.'''
41 _RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'
43 @classmethod
44 def setup_default_kwargs(cls):
45 super(AuthHandler, cls).setup_default_kwargs()
46 # Warn and ignore if AuthHandler sets an auth:
47 conf = cls.conf.setdefault('kwargs', {})
48 if 'auth' in conf: 48 ↛ 49line 48 didn't jump to line 49, because the condition on line 48 was never true
49 conf.pop('auth')
50 app_log.warning('%s: Ignoring auth on AuthHandler', cls.name)
52 @classmethod
53 def setup(cls, prepare=None, action=None, delay=None, session_expiry=None,
54 session_inactive=None, user_key='user', lookup=None, recaptcha=None, **kwargs):
55 # Switch SSL certificates if required to access Google, etc
56 gramex.service.threadpool.submit(check_old_certs)
58 # Set up default redirection based on ?next=...
59 if 'redirect' not in kwargs:
60 kwargs['redirect'] = AttrDict([('query', 'next'), ('header', 'Referer')])
61 super(AuthHandler, cls).setup(**kwargs)
63 # Set up logging for login/logout events
64 logger = logging.getLogger('gramex.user')
65 keys = objectpath(gramex.conf, 'log.handlers.user.keys', [])
66 log_info = build_log_info(keys, 'event')
67 cls.log_user_event = lambda handler, event: logger.info(log_info(handler, event))
69 # Count failed logins
70 cls.failed_logins = Counter()
71 # Set delay for failed logins from the delay: parameter which can be a number or list
72 default_delay = [1, 1, 5]
73 cls.delay = delay
74 if isinstance(cls.delay, list) and not all(isinstance(n, (int, float)) for n in cls.delay): 74 ↛ 75line 74 didn't jump to line 75, because the condition on line 74 was never true
75 app_log.warning('%s: Ignoring invalid delay: %r', cls.name, cls.delay)
76 cls.delay = default_delay
77 elif isinstance(cls.delay, (int, float)) or cls.delay is None:
78 cls.delay = default_delay
80 # Set up session user key, session expiry and inactive expiry
81 cls.session_user_key = user_key
82 cls.session_expiry = session_expiry
83 cls.session_inactive = session_inactive
85 # Set up lookup. Split a copy into self.lookup_id which has the ID, and
86 # self.lookup which has gramex.data keywords.
87 cls.lookup = None
88 if lookup is not None:
89 cls.lookup = lookup.copy()
90 if isinstance(lookup, dict): 90 ↛ 93line 90 didn't jump to line 93, because the condition on line 90 was never false
91 cls.lookup_id = cls.lookup.pop('id', 'user')
92 else:
93 app_log.error('%s: lookup must be a dict, not %s', cls.name, cls.lookup)
95 # Set up prepare
96 cls.auth_methods = {}
97 if prepare is not None:
98 cls.auth_methods['prepare'] = build_transform(
99 conf={'function': prepare},
100 vars={'handler': None, 'args': None},
101 filename='url:%s:prepare' % cls.name,
102 iter=False)
103 # Prepare recaptcha
104 if recaptcha is not None: 104 ↛ 105line 104 didn't jump to line 105, because the condition on line 104 was never true
105 if 'key' not in recaptcha:
106 app_log.error('%s: recaptcha.key missing', cls.name)
107 elif 'key' not in recaptcha:
108 app_log.error('%s: recaptcha.secret missing', cls.name)
109 else:
110 recaptcha.setdefault('action', 'login')
111 cls.auth_methods['recaptcha'] = cls.check_recaptcha
113 # Set up post-login actions
114 cls.actions = []
115 if action is not None:
116 if not isinstance(action, list): 116 ↛ 117line 116 didn't jump to line 117, because the condition on line 116 was never true
117 action = [action]
118 for conf in action:
119 cls.actions.append(build_transform(
120 conf, vars=AttrDict(handler=None),
121 filename='url:%s:%s' % (cls.name, conf.function)))
123 @coroutine
124 def prepare(self):
125 super(AuthHandler, self).prepare()
126 if 'prepare' in self.auth_methods:
127 result = yield gramex.service.threadpool.submit(
128 self.auth_methods['prepare'], handler=self, args=self.args)
129 if result is not None: 129 ↛ 130line 129 didn't jump to line 130, because the condition on line 129 was never true
130 self.args = result
131 if 'recaptcha' in self.auth_methods: 131 ↛ 132line 131 didn't jump to line 132, because the condition on line 131 was never true
132 yield self.auth_methods['recaptcha'](self, self.kwargs.recaptcha)
134 @staticmethod
135 def update_user(_user_id, **kwargs):
136 '''Update user login/logout event.'''
137 info = _user_info.load(_user_id)
138 info.update(kwargs)
139 _user_info.dump(_user_id, info)
141 @coroutine
142 def set_user(self, user, id):
143 # Find session expiry time
144 expires_days = self.session_expiry
145 if isinstance(self.session_expiry, dict):
146 # If session_expiry (se) is a dict, use se.values[args[se.key]]
147 # Or else, default to se.default - or None
148 default = self.session_expiry.get('default', None)
149 key = self.session_expiry.get('key', None)
150 val = self.get_arg(key, None)
151 lookup = self.session_expiry.get('values', {})
152 expires_days = lookup.get(val, default)
154 # When user logs in, change session ID and invalidate old session
155 # https://www.owasp.org/index.php/Session_fixation
156 self.get_session(expires_days=expires_days, new=True)
158 # The unique ID for a user varies across logins. For example, Google and
159 # Facebook provide an "id", but for Twitter, it's "username". For LDAP,
160 # it's "dn". Allow auth handlers to decide their own ID attribute and
161 # store it as "id" for consistency. Logging depends on this, for example.
162 user['id'] = user[id]
163 self.session[self.session_user_key] = user
164 self.failed_logins[user[id]] = 0
166 # Extend user attributes looking up the user ID in a lookup table
167 if self.lookup is not None:
168 # Look up the user ID in the lookup table and fetch all matching rows
169 users = yield gramex.service.threadpool.submit(
170 gramex.data.filter, args={self.lookup_id: [user['id']]}, **self.lookup)
171 if len(users) > 0 and self.lookup_id in users.columns: 171 ↛ 178line 171 didn't jump to line 178, because the condition on line 171 was never false
172 # Update the user attributes with the non-null items in the looked up row
173 user.update({
174 key: val for key, val in users.iloc[0].iteritems()
175 if not gramex.data.pd.isnull(val)
176 })
178 self.update_user(user[id], active='y', **user)
180 # If session_inactive: is specified, set expiry date on the session
181 if self.session_inactive is not None:
182 self.session['_i'] = self.session_inactive * 24 * 60 * 60
184 # Run post-login events (e.g. ensure_single_session) specified in config
185 for callback in self.actions:
186 callback(self)
187 self.log_user_event(event='login')
189 @coroutine
190 def fail_user(self, user, id):
191 '''
192 When user login fails, delay response. Delay = self.delay[# of failures].
193 Or use the last value in the self.delay[] array.
194 Return # failures
195 '''
196 failures = self.failed_logins[user[id]] = self.failed_logins[user[id]] + 1
197 index = failures - 1
198 delay = self.delay[index] if index < len(self.delay) else self.delay[-1]
199 yield sleep(delay)
201 def render_template(self, path, **kwargs):
202 '''
203 Like self.render(), but reloads updated templates.
204 '''
205 template = gramex.cache.open(path, 'template')
206 namespace = self.get_template_namespace()
207 namespace.update(kwargs)
208 self.finish(template.generate(**namespace))
210 @coroutine
211 def check_recaptcha(self, conf):
212 if self.request.method != 'POST':
213 return
214 token = self.get_argument('recaptcha', None)
215 if token is None:
216 raise HTTPError(FORBIDDEN, "'recaptcha' argument missing from POST")
217 body = six.moves.urllib_parse.urlencode({
218 'secret': conf.secret,
219 'response': token,
220 'remoteip': self.request.remote_ip
221 })
222 http = tornado.httpclient.AsyncHTTPClient()
223 response = yield http.fetch(self._RECAPTCHA_VERIFY_URL, method='POST', body=body)
224 result = json.loads(response.body)
225 if not result['success']:
226 raise HTTPError(FORBIDDEN, 'recaptcha failed: %s' % ', '.join(result['error-codes']))
229class LogoutHandler(AuthHandler):
230 def get(self):
231 self.save_redirect_page()
232 for callback in self.actions: 232 ↛ 233line 232 didn't jump to line 233, because the loop on line 232 never started
233 callback(self)
234 self.log_user_event(event='logout')
235 user = self.session.get(self.session_user_key, {})
236 if 'id' in user:
237 self.update_user(user['id'], active='')
238 self.session.pop(self.session_user_key, None)
239 if self.redirects: 239 ↛ exitline 239 didn't return from function 'get', because the condition on line 239 was never false
240 self.redirect_next()
243class GoogleAuth(AuthHandler, GoogleOAuth2Mixin):
244 @coroutine
245 def get(self):
246 self.settings[self._OAUTH_SETTINGS_KEY] = {
247 'key': self.kwargs['key'],
248 'secret': self.kwargs['secret']
249 }
250 redirect_uri = '{0.protocol:s}://{0.host:s}{0.path:s}'.format(self.request)
251 code = self.get_arg('code', '')
252 if code:
253 access = yield self.get_authenticated_user(
254 redirect_uri=redirect_uri,
255 code=code)
256 user = yield self.oauth2_request(
257 'https://www.googleapis.com/oauth2/v1/userinfo',
258 access_token=access['access_token'])
259 yield self.set_user(user, id='email')
260 self.session['google_access_token'] = access['access_token']
261 self.redirect_next()
262 else:
263 self.save_redirect_page()
264 # Ensure user-specified scope has 'profile' and 'email'
265 scope = self.kwargs.get('scope', [])
266 scope = scope if isinstance(scope, list) else [scope]
267 scope = list(set(scope) | {'profile', 'email'})
268 # Ensure extra_params has auto approval prompt
269 extra_params = self.kwargs.get('extra_params', {})
270 if 'approval_prompt' not in extra_params:
271 extra_params['approval_prompt'] = 'auto'
272 # Return the list
273 yield self.authorize_redirect(
274 redirect_uri=redirect_uri,
275 client_id=self.kwargs['key'],
276 scope=scope,
277 response_type='code',
278 extra_params=extra_params)
281class SimpleAuth(AuthHandler):
282 '''
283 Eventually, change this to use an abstract base class for local
284 authentication methods -- i.e. where **we** render the login screen, not a third party service.
286 The login page is rendered in case of a login error as well. The page is a
287 Tornado template that is passed an ``error`` variable. ``error`` is ``None``
288 by default. If the login fails, it must be a ``dict`` with attributes
289 specific to the handler.
291 The simplest configuration (``kwargs``) for SimpleAuth is::
293 credentials: # Mapping of user IDs and passwords
294 user1: password1 # user1 maps to password1
295 user2: password2
297 An alternate configuration is::
299 credentials: # Mapping of user IDs and user info
300 user1: # Each user ID has a dictionary of keys
301 password: password1 # One of them MUST be password
302 email: user1@example.org # Any other attributes can be added
303 role: employee # These are available from the session info
304 user2:
305 password: password2
306 email: user2@example.org
307 role: manager
309 The full configuration (``kwargs``) for SimpleAuth looks like this::
311 template: $YAMLPATH/auth.template.html # Render the login form template
312 user:
313 arg: user # ... the ?user= argument from the form.
314 password:
315 arg: password # ... the ?password= argument from the form
316 data:
317 ... # Same as above
319 The login flow is as follows:
321 1. User visits the SimpleAuth page => shows template (with the user name and password inputs)
322 2. User enters user name and password, and submits. Browser redirects with a POST request
323 3. Application checks username and password. On match, redirects.
324 4. On any error, shows template (with error)
325 '''
326 @classmethod
327 def setup(cls, **kwargs):
328 super(SimpleAuth, cls).setup(**kwargs)
329 cls.template = kwargs.get('template', _auth_template)
330 cls.user = kwargs.get('user', AttrDict())
331 cls.password = kwargs.get('password', AttrDict())
332 cls.credentials = kwargs.get('credentials', {})
333 cls.user.setdefault('arg', 'user')
334 cls.password.setdefault('arg', 'password')
336 @coroutine
337 def get(self):
338 self.save_redirect_page()
339 self.render_template(self.template, error=None)
341 @coroutine
342 def post(self):
343 user = self.get_arg(self.user.arg, None)
344 password = self.get_arg(self.password.arg, None)
345 info = self.credentials.get(user)
346 if info == password:
347 yield self.set_user({'user': user}, id='user')
348 self.redirect_next()
349 elif hasattr(info, 'get') and info.get('password', None) == password:
350 info.setdefault('user', user)
351 yield self.set_user(dict(info), id='user')
352 self.redirect_next()
353 else:
354 yield self.fail_user({'user': user}, id='user')
355 self.log_user_event(event='fail')
356 self.set_status(UNAUTHORIZED)
357 self.render_template(self.template, error={'code': 'auth', 'error': 'Cannot log in'})
360class OTP(object):
361 '''
362 OTP: One-time password. Also used for password recovery
363 '''
364 def __init__(self, size=None):
365 '''
366 Set up the database that stores password recovery tokens.
367 ``size`` is the length of the OTP in characters. Defaults to the
368 full hashing string
369 '''
370 self.size = size
371 # create database at GRAMEXDATA
372 path = os.path.join(gramex.variables.GRAMEXDATA, 'auth.recover.db')
373 url = 'sqlite:///{}'.format(path)
374 self.engine = gramex.data.create_engine(url, encoding=str_utf8)
375 conn = self.engine.connect()
376 conn.execute('CREATE TABLE IF NOT EXISTS users '
377 '(user TEXT, email TEXT, token TEXT, expire REAL)')
378 self.table = gramex.data.get_table(self.engine, 'users')
380 def token(self, user, email, expire):
381 '''Generate a one-tie token, store it in the recovery database, and return it'''
382 token = uuid.uuid4().hex[:self.size]
383 query = self.table.insert().values({
384 'user': user, 'email': email, 'token': token, 'expire': expire,
385 })
386 self.engine.execute(query)
387 return token
389 def pop(self, token):
390 '''Return the row matching the token, and deletes it from the list'''
391 where = self.table.c['token'] == token
392 query = self.table.select().where(where)
393 result = self.engine.execute(query)
394 if result.returns_rows:
395 row = result.fetchone()
396 if row is not None:
397 self.engine.execute(self.table.delete(where))
398 if row['expire'] >= time.time():
399 return row
400 return None
403def csv_encode(values, *args, **kwargs):
404 '''
405 Encode an array of unicode values into a comma-separated string. All
406 csv.writer parameters are valid.
407 '''
408 buf = StringIOClass()
409 writer = csv.writer(buf, *args, **kwargs)
410 writer.writerow([
411 v if isinstance(v, six.text_type) else
412 v.decode('utf-8') if isinstance(v, six.binary_type) else repr(v)
413 for v in values])
414 return buf.getvalue().strip()