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