Hide keyboard shortcuts

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 

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) 

31try: 

32 import cStringIO 

33 StringIOClass = cStringIO.StringIO 

34except ImportError: 

35 import io 

36 StringIOClass = io.StringIO 

37 

38 

39class 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 

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

241 

242 

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) 

279 

280 

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. 

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 

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

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 

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