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

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

## 225 statements 156 run 69 missing 0 excluded 10 partial

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

1 import os

2 import csv

3 import six

4 import json

5 import time

6 import uuid

7 import logging

8 import tornado.escape

9 import tornado.httpclient

10 from tornado.auth import GoogleOAuth2Mixin

11 from tornado.gen import coroutine, sleep

12 from tornado.web import HTTPError

13 from collections import Counter

14 from orderedattrdict import AttrDict

15 import gramex

16 import gramex.cache

17 from gramex.http import UNAUTHORIZED, FORBIDDEN

18 from gramex.config import check_old_certs, app_log, objectpath, str_utf8

19 from gramex.transforms import build_transform

20 from .basehandler import BaseHandler, build_log_info

21

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')

26

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)

31 try:

32 import cStringIO

33 StringIOClass = cStringIO.StringIO

34 except ImportError:

35 import io

36 StringIOClass = io.StringIO

37

38

39 class AuthHandler(BaseHandler):

40 '''The parent handler for all Auth handlers.'''

41 _RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'

42

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)

51

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)

57

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)

62

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))

68

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

79

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

84

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)

94

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

112

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)))

122

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)

133

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)

140

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)

153

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)

157

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

165

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 })

177

178 self.update_user(user[id], active='y', **user)

179

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

183

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')

188

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)

200

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))

209

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']))

227

228

229 class 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()

241

242

243 class 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)

279

280

281 class 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.

285

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.

290

291 The simplest configuration (``kwargs``) for SimpleAuth is::

292

293 credentials: # Mapping of user IDs and passwords

294 user1: password1 # user1 maps to password1

295 user2: password2

296

297 An alternate configuration is::

298

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

308

309 The full configuration (``kwargs``) for SimpleAuth looks like this::

310

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

318

319 The login flow is as follows:

320

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')

335

336 @coroutine

337 def get(self):

338 self.save_redirect_page()

339 self.render_template(self.template, error=None)

340

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'})

358

359

360 class 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')

379

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

388

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

401

402

403 def 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()