##// END OF EJS Templates
allow LoginHandler to override get_current_user
Min RK -
Show More
@@ -1,70 +1,88 b''
1 1 """Tornado handlers for logging into the notebook."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import uuid
7 7
8 8 from tornado.escape import url_escape
9 9
10 10 from IPython.lib.security import passwd_check
11 11
12 12 from ..base.handlers import IPythonHandler
13 13
14 14
15 15 class LoginHandler(IPythonHandler):
16 16 """The basic tornado login handler
17 17
18 18 authenticates with a hashed password from the configuration.
19 19 """
20 20 def _render(self, message=None):
21 21 self.write(self.render_template('login.html',
22 22 next=url_escape(self.get_argument('next', default=self.base_url)),
23 23 message=message,
24 24 ))
25 25
26 26 def get(self):
27 27 if self.current_user:
28 28 self.redirect(self.get_argument('next', default=self.base_url))
29 29 else:
30 30 self._render()
31 31
32 32 @property
33 33 def hashed_password(self):
34 34 return self.password_from_settings(self.settings)
35 35
36 36 def post(self):
37 37 typed_password = self.get_argument('password', default=u'')
38 38 if self.login_available(self.settings):
39 39 if passwd_check(self.hashed_password, typed_password):
40 40 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
41 41 else:
42 42 self._render(message={'error': 'Invalid password'})
43 43 return
44 44
45 45 self.redirect(self.get_argument('next', default=self.base_url))
46 46
47 @staticmethod
48 def get_user(handler):
49 """Called by handlers for identifying the current user."""
50 # Can't call this get_current_user because it will collide when
51 # called on LoginHandler itself.
52
53 user_id = handler.get_secure_cookie(handler.cookie_name)
54 # For now the user_id should not return empty, but it could eventually
55 if user_id == '':
56 user_id = 'anonymous'
57 if user_id is None:
58 # prevent extra Invalid cookie sig warnings:
59 handler.clear_login_cookie()
60 if not handler.login_available:
61 user_id = 'anonymous'
62 return user_id
63
64
47 65 @classmethod
48 66 def validate_notebook_app_security(cls, notebook_app, ssl_options=None):
49 67 if not notebook_app.ip:
50 68 warning = "WARNING: The notebook server is listening on all IP addresses"
51 69 if ssl_options is None:
52 70 notebook_app.log.critical(warning + " and not using encryption. This "
53 71 "is not recommended.")
54 72 if not notebook_app.password:
55 73 notebook_app.log.critical(warning + " and not using authentication. "
56 74 "This is highly insecure and not recommended.")
57 75
58 76 @staticmethod
59 77 def password_from_settings(settings):
60 78 """Return the hashed password from the tornado settings.
61 79
62 80 If there is no configured password, an empty string will be returned.
63 81 """
64 82 return settings.get('password', u'')
65 83
66 84 @classmethod
67 85 def login_available(cls, settings):
68 86 """Whether this LoginHandler is needed - and therefore whether the login page should be displayed."""
69 87 return bool(cls.password_from_settings(settings))
70 88
@@ -1,514 +1,507 b''
1 1 """Base Tornado handlers for the notebook server."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import functools
7 7 import json
8 8 import logging
9 9 import os
10 10 import re
11 11 import sys
12 12 import traceback
13 13 try:
14 14 # py3
15 15 from http.client import responses
16 16 except ImportError:
17 17 from httplib import responses
18 18
19 19 from jinja2 import TemplateNotFound
20 20 from tornado import web
21 21
22 22 try:
23 23 from tornado.log import app_log
24 24 except ImportError:
25 25 app_log = logging.getLogger()
26 26
27 27 import IPython
28 28 from IPython.utils.sysinfo import get_sys_info
29 29
30 30 from IPython.config import Application
31 31 from IPython.utils.path import filefind
32 32 from IPython.utils.py3compat import string_types
33 33 from IPython.html.utils import is_hidden, url_path_join, url_escape
34 34
35 35 from IPython.html.services.security import csp_report_uri
36 36
37 37 #-----------------------------------------------------------------------------
38 38 # Top-level handlers
39 39 #-----------------------------------------------------------------------------
40 40 non_alphanum = re.compile(r'[^A-Za-z0-9]')
41 41
42 42 sys_info = json.dumps(get_sys_info())
43 43
44 44 class AuthenticatedHandler(web.RequestHandler):
45 45 """A RequestHandler with an authenticated user."""
46 46
47 47 def set_default_headers(self):
48 48 headers = self.settings.get('headers', {})
49 49
50 50 if "Content-Security-Policy" not in headers:
51 51 headers["Content-Security-Policy"] = (
52 52 "frame-ancestors 'self'; "
53 53 # Make sure the report-uri is relative to the base_url
54 54 "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";"
55 55 )
56 56
57 57 # Allow for overriding headers
58 58 for header_name,value in headers.items() :
59 59 try:
60 60 self.set_header(header_name, value)
61 61 except Exception as e:
62 62 # tornado raise Exception (not a subclass)
63 63 # if method is unsupported (websocket and Access-Control-Allow-Origin
64 64 # for example, so just ignore)
65 65 self.log.debug(e)
66 66
67 67 def clear_login_cookie(self):
68 68 self.clear_cookie(self.cookie_name)
69 69
70 70 def get_current_user(self):
71 user_id = self.get_secure_cookie(self.cookie_name)
72 # For now the user_id should not return empty, but it could eventually
73 if user_id == '':
74 user_id = 'anonymous'
75 if user_id is None:
76 # prevent extra Invalid cookie sig warnings:
77 self.clear_login_cookie()
78 if not self.login_available:
79 user_id = 'anonymous'
80 return user_id
71 if self.login_handler is None:
72 return 'anonymous'
73 return self.login_handler.get_user(self)
81 74
82 75 @property
83 76 def cookie_name(self):
84 77 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
85 78 self.request.host
86 79 ))
87 80 return self.settings.get('cookie_name', default_cookie_name)
88 81
89 82 @property
90 83 def logged_in(self):
91 84 """Is a user currently logged in?"""
92 85 user = self.get_current_user()
93 86 return (user and not user == 'anonymous')
94 87
95 88 @property
96 89 def login_handler(self):
97 90 """Return the login handler for this application."""
98 91 return self.settings.get('login_handler_class', None)
99 92
100 93 @property
101 94 def login_available(self):
102 95 """May a user proceed to log in?
103 96
104 97 This returns True if login capability is available, irrespective of
105 98 whether the user is already logged in or not.
106 99
107 100 """
108 101 if self.login_handler is None:
109 102 return False
110 103 return bool(self.login_handler.login_available(self.settings))
111 104
112 105
113 106 class IPythonHandler(AuthenticatedHandler):
114 107 """IPython-specific extensions to authenticated handling
115 108
116 109 Mostly property shortcuts to IPython-specific settings.
117 110 """
118 111
119 112 @property
120 113 def config(self):
121 114 return self.settings.get('config', None)
122 115
123 116 @property
124 117 def log(self):
125 118 """use the IPython log by default, falling back on tornado's logger"""
126 119 if Application.initialized():
127 120 return Application.instance().log
128 121 else:
129 122 return app_log
130 123
131 124 #---------------------------------------------------------------
132 125 # URLs
133 126 #---------------------------------------------------------------
134 127
135 128 @property
136 129 def version_hash(self):
137 130 """The version hash to use for cache hints for static files"""
138 131 return self.settings.get('version_hash', '')
139 132
140 133 @property
141 134 def mathjax_url(self):
142 135 return self.settings.get('mathjax_url', '')
143 136
144 137 @property
145 138 def base_url(self):
146 139 return self.settings.get('base_url', '/')
147 140
148 141 @property
149 142 def ws_url(self):
150 143 return self.settings.get('websocket_url', '')
151 144
152 145 @property
153 146 def contents_js_source(self):
154 147 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
155 148 'services/contents'))
156 149 return self.settings.get('contents_js_source', 'services/contents')
157 150
158 151 #---------------------------------------------------------------
159 152 # Manager objects
160 153 #---------------------------------------------------------------
161 154
162 155 @property
163 156 def kernel_manager(self):
164 157 return self.settings['kernel_manager']
165 158
166 159 @property
167 160 def contents_manager(self):
168 161 return self.settings['contents_manager']
169 162
170 163 @property
171 164 def cluster_manager(self):
172 165 return self.settings['cluster_manager']
173 166
174 167 @property
175 168 def session_manager(self):
176 169 return self.settings['session_manager']
177 170
178 171 @property
179 172 def terminal_manager(self):
180 173 return self.settings['terminal_manager']
181 174
182 175 @property
183 176 def kernel_spec_manager(self):
184 177 return self.settings['kernel_spec_manager']
185 178
186 179 @property
187 180 def config_manager(self):
188 181 return self.settings['config_manager']
189 182
190 183 #---------------------------------------------------------------
191 184 # CORS
192 185 #---------------------------------------------------------------
193 186
194 187 @property
195 188 def allow_origin(self):
196 189 """Normal Access-Control-Allow-Origin"""
197 190 return self.settings.get('allow_origin', '')
198 191
199 192 @property
200 193 def allow_origin_pat(self):
201 194 """Regular expression version of allow_origin"""
202 195 return self.settings.get('allow_origin_pat', None)
203 196
204 197 @property
205 198 def allow_credentials(self):
206 199 """Whether to set Access-Control-Allow-Credentials"""
207 200 return self.settings.get('allow_credentials', False)
208 201
209 202 def set_default_headers(self):
210 203 """Add CORS headers, if defined"""
211 204 super(IPythonHandler, self).set_default_headers()
212 205 if self.allow_origin:
213 206 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
214 207 elif self.allow_origin_pat:
215 208 origin = self.get_origin()
216 209 if origin and self.allow_origin_pat.match(origin):
217 210 self.set_header("Access-Control-Allow-Origin", origin)
218 211 if self.allow_credentials:
219 212 self.set_header("Access-Control-Allow-Credentials", 'true')
220 213
221 214 def get_origin(self):
222 215 # Handle WebSocket Origin naming convention differences
223 216 # The difference between version 8 and 13 is that in 8 the
224 217 # client sends a "Sec-Websocket-Origin" header and in 13 it's
225 218 # simply "Origin".
226 219 if "Origin" in self.request.headers:
227 220 origin = self.request.headers.get("Origin")
228 221 else:
229 222 origin = self.request.headers.get("Sec-Websocket-Origin", None)
230 223 return origin
231 224
232 225 #---------------------------------------------------------------
233 226 # template rendering
234 227 #---------------------------------------------------------------
235 228
236 229 def get_template(self, name):
237 230 """Return the jinja template object for a given name"""
238 231 return self.settings['jinja2_env'].get_template(name)
239 232
240 233 def render_template(self, name, **ns):
241 234 ns.update(self.template_namespace)
242 235 template = self.get_template(name)
243 236 return template.render(**ns)
244 237
245 238 @property
246 239 def template_namespace(self):
247 240 return dict(
248 241 base_url=self.base_url,
249 242 ws_url=self.ws_url,
250 243 logged_in=self.logged_in,
251 244 login_available=self.login_available,
252 245 static_url=self.static_url,
253 246 sys_info=sys_info,
254 247 contents_js_source=self.contents_js_source,
255 248 version_hash=self.version_hash,
256 249 )
257 250
258 251 def get_json_body(self):
259 252 """Return the body of the request as JSON data."""
260 253 if not self.request.body:
261 254 return None
262 255 # Do we need to call body.decode('utf-8') here?
263 256 body = self.request.body.strip().decode(u'utf-8')
264 257 try:
265 258 model = json.loads(body)
266 259 except Exception:
267 260 self.log.debug("Bad JSON: %r", body)
268 261 self.log.error("Couldn't parse JSON", exc_info=True)
269 262 raise web.HTTPError(400, u'Invalid JSON in body of request')
270 263 return model
271 264
272 265 def write_error(self, status_code, **kwargs):
273 266 """render custom error pages"""
274 267 exc_info = kwargs.get('exc_info')
275 268 message = ''
276 269 status_message = responses.get(status_code, 'Unknown HTTP Error')
277 270 if exc_info:
278 271 exception = exc_info[1]
279 272 # get the custom message, if defined
280 273 try:
281 274 message = exception.log_message % exception.args
282 275 except Exception:
283 276 pass
284 277
285 278 # construct the custom reason, if defined
286 279 reason = getattr(exception, 'reason', '')
287 280 if reason:
288 281 status_message = reason
289 282
290 283 # build template namespace
291 284 ns = dict(
292 285 status_code=status_code,
293 286 status_message=status_message,
294 287 message=message,
295 288 exception=exception,
296 289 )
297 290
298 291 self.set_header('Content-Type', 'text/html')
299 292 # render the template
300 293 try:
301 294 html = self.render_template('%s.html' % status_code, **ns)
302 295 except TemplateNotFound:
303 296 self.log.debug("No template for %d", status_code)
304 297 html = self.render_template('error.html', **ns)
305 298
306 299 self.write(html)
307 300
308 301
309 302
310 303 class Template404(IPythonHandler):
311 304 """Render our 404 template"""
312 305 def prepare(self):
313 306 raise web.HTTPError(404)
314 307
315 308
316 309 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
317 310 """static files should only be accessible when logged in"""
318 311
319 312 @web.authenticated
320 313 def get(self, path):
321 314 if os.path.splitext(path)[1] == '.ipynb':
322 315 name = path.rsplit('/', 1)[-1]
323 316 self.set_header('Content-Type', 'application/json')
324 317 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
325 318
326 319 return web.StaticFileHandler.get(self, path)
327 320
328 321 def set_headers(self):
329 322 super(AuthenticatedFileHandler, self).set_headers()
330 323 # disable browser caching, rely on 304 replies for savings
331 324 if "v" not in self.request.arguments:
332 325 self.add_header("Cache-Control", "no-cache")
333 326
334 327 def compute_etag(self):
335 328 return None
336 329
337 330 def validate_absolute_path(self, root, absolute_path):
338 331 """Validate and return the absolute path.
339 332
340 333 Requires tornado 3.1
341 334
342 335 Adding to tornado's own handling, forbids the serving of hidden files.
343 336 """
344 337 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
345 338 abs_root = os.path.abspath(root)
346 339 if is_hidden(abs_path, abs_root):
347 340 self.log.info("Refusing to serve hidden file, via 404 Error")
348 341 raise web.HTTPError(404)
349 342 return abs_path
350 343
351 344
352 345 def json_errors(method):
353 346 """Decorate methods with this to return GitHub style JSON errors.
354 347
355 348 This should be used on any JSON API on any handler method that can raise HTTPErrors.
356 349
357 350 This will grab the latest HTTPError exception using sys.exc_info
358 351 and then:
359 352
360 353 1. Set the HTTP status code based on the HTTPError
361 354 2. Create and return a JSON body with a message field describing
362 355 the error in a human readable form.
363 356 """
364 357 @functools.wraps(method)
365 358 def wrapper(self, *args, **kwargs):
366 359 try:
367 360 result = method(self, *args, **kwargs)
368 361 except web.HTTPError as e:
369 362 status = e.status_code
370 363 message = e.log_message
371 364 self.log.warn(message)
372 365 self.set_status(e.status_code)
373 366 self.finish(json.dumps(dict(message=message)))
374 367 except Exception:
375 368 self.log.error("Unhandled error in API request", exc_info=True)
376 369 status = 500
377 370 message = "Unknown server error"
378 371 t, value, tb = sys.exc_info()
379 372 self.set_status(status)
380 373 tb_text = ''.join(traceback.format_exception(t, value, tb))
381 374 reply = dict(message=message, traceback=tb_text)
382 375 self.finish(json.dumps(reply))
383 376 else:
384 377 return result
385 378 return wrapper
386 379
387 380
388 381
389 382 #-----------------------------------------------------------------------------
390 383 # File handler
391 384 #-----------------------------------------------------------------------------
392 385
393 386 # to minimize subclass changes:
394 387 HTTPError = web.HTTPError
395 388
396 389 class FileFindHandler(web.StaticFileHandler):
397 390 """subclass of StaticFileHandler for serving files from a search path"""
398 391
399 392 # cache search results, don't search for files more than once
400 393 _static_paths = {}
401 394
402 395 def set_headers(self):
403 396 super(FileFindHandler, self).set_headers()
404 397 # disable browser caching, rely on 304 replies for savings
405 398 if "v" not in self.request.arguments or \
406 399 any(self.request.path.startswith(path) for path in self.no_cache_paths):
407 400 self.add_header("Cache-Control", "no-cache")
408 401
409 402 def initialize(self, path, default_filename=None, no_cache_paths=None):
410 403 self.no_cache_paths = no_cache_paths or []
411 404
412 405 if isinstance(path, string_types):
413 406 path = [path]
414 407
415 408 self.root = tuple(
416 409 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
417 410 )
418 411 self.default_filename = default_filename
419 412
420 413 def compute_etag(self):
421 414 return None
422 415
423 416 @classmethod
424 417 def get_absolute_path(cls, roots, path):
425 418 """locate a file to serve on our static file search path"""
426 419 with cls._lock:
427 420 if path in cls._static_paths:
428 421 return cls._static_paths[path]
429 422 try:
430 423 abspath = os.path.abspath(filefind(path, roots))
431 424 except IOError:
432 425 # IOError means not found
433 426 return ''
434 427
435 428 cls._static_paths[path] = abspath
436 429 return abspath
437 430
438 431 def validate_absolute_path(self, root, absolute_path):
439 432 """check if the file should be served (raises 404, 403, etc.)"""
440 433 if absolute_path == '':
441 434 raise web.HTTPError(404)
442 435
443 436 for root in self.root:
444 437 if (absolute_path + os.sep).startswith(root):
445 438 break
446 439
447 440 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
448 441
449 442
450 443 class ApiVersionHandler(IPythonHandler):
451 444
452 445 @json_errors
453 446 def get(self):
454 447 # not authenticated, so give as few info as possible
455 448 self.finish(json.dumps({"version":IPython.__version__}))
456 449
457 450
458 451 class TrailingSlashHandler(web.RequestHandler):
459 452 """Simple redirect handler that strips trailing slashes
460 453
461 454 This should be the first, highest priority handler.
462 455 """
463 456
464 457 def get(self):
465 458 self.redirect(self.request.uri.rstrip('/'))
466 459
467 460 post = put = get
468 461
469 462
470 463 class FilesRedirectHandler(IPythonHandler):
471 464 """Handler for redirecting relative URLs to the /files/ handler"""
472 465 def get(self, path=''):
473 466 cm = self.contents_manager
474 467 if cm.dir_exists(path):
475 468 # it's a *directory*, redirect to /tree
476 469 url = url_path_join(self.base_url, 'tree', path)
477 470 else:
478 471 orig_path = path
479 472 # otherwise, redirect to /files
480 473 parts = path.split('/')
481 474
482 475 if not cm.file_exists(path=path) and 'files' in parts:
483 476 # redirect without files/ iff it would 404
484 477 # this preserves pre-2.0-style 'files/' links
485 478 self.log.warn("Deprecated files/ URL: %s", orig_path)
486 479 parts.remove('files')
487 480 path = '/'.join(parts)
488 481
489 482 if not cm.file_exists(path=path):
490 483 raise web.HTTPError(404)
491 484
492 485 url = url_path_join(self.base_url, 'files', path)
493 486 url = url_escape(url)
494 487 self.log.debug("Redirecting %s to %s", self.request.path, url)
495 488 self.redirect(url)
496 489
497 490
498 491 #-----------------------------------------------------------------------------
499 492 # URL pattern fragments for re-use
500 493 #-----------------------------------------------------------------------------
501 494
502 495 # path matches any number of `/foo[/bar...]` or just `/` or ''
503 496 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
504 497 notebook_path_regex = r"(?P<path>(?:/[^/]+)+\.ipynb)"
505 498
506 499 #-----------------------------------------------------------------------------
507 500 # URL to handler mappings
508 501 #-----------------------------------------------------------------------------
509 502
510 503
511 504 default_handlers = [
512 505 (r".*/", TrailingSlashHandler),
513 506 (r"api", ApiVersionHandler)
514 507 ]
General Comments 0
You need to be logged in to leave comments. Login now