From f420ba1c5208f3f7a6e238ffc3cfc1cafa7e5529 Mon Sep 17 00:00:00 2001
From: btribonde <49305499+btribonde@users.noreply.github.com>
Date: Mon, 23 Mar 2020 14:58:54 +0100
Subject: [PATCH] Add SSL configuration for jupytab (#26)

SSL configuration for jupytab
---
 .gitignore                                   |  3 +
 CHANGELOG.md                                 |  7 +-
 README.md                                    |  6 ++
 VERSION                                      |  2 +-
 jupytab-server/jupytab_server/jupytab.py     | 76 ++++++++++++++++----
 jupytab-server/tests/config/example.cert     | 19 +++++
 jupytab-server/tests/config/example.key      | 27 +++++++
 jupytab-server/tests/config/ssl_bad_file.ini |  4 ++
 jupytab-server/tests/config/ssl_disabled.ini |  4 ++
 jupytab-server/tests/config/ssl_none.ini     |  1 +
 jupytab-server/tests/config/ssl_ok.ini       |  4 ++
 jupytab-server/tests/test_config.py          | 25 +++++++
 12 files changed, 160 insertions(+), 18 deletions(-)
 create mode 100644 jupytab-server/tests/config/example.cert
 create mode 100644 jupytab-server/tests/config/example.key
 create mode 100644 jupytab-server/tests/config/ssl_bad_file.ini
 create mode 100644 jupytab-server/tests/config/ssl_disabled.ini
 create mode 100644 jupytab-server/tests/config/ssl_none.ini
 create mode 100644 jupytab-server/tests/config/ssl_ok.ini
 create mode 100644 jupytab-server/tests/test_config.py

diff --git a/.gitignore b/.gitignore
index ba6c570..6bffd30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
 # Custom
 test_*.sh
+build.sh
+deploy.sh
+.pypirc
 
 # Byte-compiled / optimized / DLL files
 __pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 230d76f..c887645 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,12 @@
 # Changelog
 
-## 0.9.3 (2019-02-25)
+## 0.9.4 (2020-03-23)
+* #24 Add support for SSL
+
+## 0.9.3 (2020-02-25)
 * Fix incorrect release on pypi following jupytab split
 
-## 0.9.2 (2019-02-24)
+## 0.9.2 (2020-02-24)
 * #19 Create a tool library for jupytab, allowing separated download for jupytab util
 * #7 Jupytab must be install in kernel as no-deps
 * #16 notebook.js use parent path instead of same level path
diff --git a/README.md b/README.md
index e9a0f6a..af0a9fc 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,9 @@ You need to create a `config.ini` file in order to tell Jupytab which notebooks
 listen_port = 8765
 security_token = myToken
 notebooks = AirFlights|RealEstateCrime
+ssl_enabled = True
+ssl_key = /etc/pki/tls/certs/file.crt
+ssl_cert = /etc/pki/tls/private/file.key
 
 [AirFlights]
 name = Air Flights
@@ -112,6 +115,9 @@ There is only one mandatory section, `main`, which contains:
 and separated by the `|` (pipe) symbol. This must be a simple name compliant with [configparser](https://docs.python.org/3/library/configparser.html) sections.
 * `security_token` (optional): If provided, an encrypted security token will be required for all exchanges with
 Jupytab.
+* `ssl_enabled` (optional): Enable or disable SSL
+* `ssl_key` (mandatory if ssl_enabled is true): The path name of the server private key file
+* `ssl_cert` (mandatory if ssl_enabled is true): The path name of the server public key certificate file
  
 Additional sections contain information about each notebook to be run:
 
diff --git a/VERSION b/VERSION
index b3ec163..2bd77c7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.9.3
\ No newline at end of file
+0.9.4
\ No newline at end of file
diff --git a/jupytab-server/jupytab_server/jupytab.py b/jupytab-server/jupytab_server/jupytab.py
index 577c9be..8f13226 100644
--- a/jupytab-server/jupytab_server/jupytab.py
+++ b/jupytab-server/jupytab_server/jupytab.py
@@ -11,9 +11,9 @@
 from tornado.ioloop import IOLoop
 from tornado.web import StaticFileHandler, Application
 
-from jupytab_server.kernel_executor import KernelExecutor
 from jupytab_server.jupytab_api import RestartHandler, APIHandler, ReverseProxyHandler, root, \
     api_kernel, access_kernel, restart_kernel
+from jupytab_server.kernel_executor import KernelExecutor
 
 logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO)
 
@@ -28,20 +28,18 @@ def __extract_item(config, notebook_key, item_key, default=None):
             raise ValueError(f'Expecting {item_key} in section {notebook_key}')
 
 
-def parse_config(config_file):
-    if not os.path.isfile(config_file):
-        raise FileNotFoundError(f"missing configuration: {config_file}")
+def config_listen_port(config):
+    return config.getint('main', 'listen_port')
 
-    config = ConfigParser()
-    config.optionxform = str
-    config.read(config_file)
 
-    listen_port = config.getint('main', 'listen_port')
+def config_security_token(config):
     try:
-        security_token = config.get('main', 'security_token')
+        return config.get('main', 'security_token')
     except (NoSectionError, NoOptionError):
-        security_token = None
+        return None
 
+
+def config_notebooks(config):
     notebooks = config.get('main', 'notebooks')
     notebook_dict = {}
 
@@ -56,14 +54,59 @@ def parse_config(config_file):
                               'description': nb_description,
                               'cwd': nb_cwd}
 
+    return notebook_dict
+
+
+def config_ssl(config):
+    try:
+        ssl_enabled = config.getboolean('main', 'ssl_enabled')
+    except (NoSectionError, NoOptionError):
+        ssl_enabled = False
+
+    if ssl_enabled:
+        ssl_cert = config.get('main', 'ssl_cert')
+        ssl_key = config.get('main', 'ssl_key')
+
+        if ssl_enabled and not os.path.isfile(ssl_cert):
+            raise FileNotFoundError(f"SSL enabled but missing ssl_cert file: {ssl_cert}")
+        if ssl_enabled and not os.path.isfile(ssl_key):
+            raise FileNotFoundError(f"SSL enabled but missing ssl_key file: {ssl_key}")
+
+        return {
+            "certfile": ssl_cert,
+            "keyfile": ssl_key
+        }
+    else:
+        return None
+
+
+def parse_config(config_file):
+    if not os.path.isfile(config_file):
+        raise FileNotFoundError(f"missing configuration: {config_file}")
+
+    config = ConfigParser()
+    config.optionxform = str
+    config.read(config_file)
+
+    listen_port = config_listen_port(config)
+    security_token = config_security_token(config)
+    notebooks = config_notebooks(config)
+    ssl = config_ssl(config)
+
+    if not ssl:
+        print("SSL not enabled")
+    else:
+        print("SSL enabled")
+
     return {
         'listen_port': listen_port,
         'security_token': security_token,
-        'notebooks': notebook_dict
+        'notebooks': notebooks,
+        'ssl': ssl
     }
 
 
-def create_server_app(listen_port, security_token, notebooks):
+def create_server_app(listen_port, security_token, notebooks, ssl):
     notebook_store = {}
 
     for key, value in notebooks.items():
@@ -72,14 +115,17 @@ def create_server_app(listen_port, security_token, notebooks):
     for key, value in notebook_store.items():
         value.start()
 
+    protocol = "https" if ssl else "http"
+
     if security_token:
         token_digest = hashlib.sha224(security_token.encode('utf-8')).hexdigest()
         print(f"""Your token is {token_digest}
-        Please open : http://{socket.gethostname()}:{listen_port}/?security_token={token_digest}""")
+        Please open : {protocol}://{socket.gethostname()}:{listen_port}"""
+              f"/?security_token={token_digest}")
     else:
         token_digest = None
         print(f"""You have no defined token. Please note your process is not secured !
-        Please open : http://{socket.gethostname()}:{listen_port}""")
+        Please open : {protocol}://{socket.gethostname()}:{listen_port}""")
 
     server_app = Application([
         (r"/" + api_kernel, APIHandler,
@@ -105,7 +151,7 @@ def main(input_args=None):
 
     app = create_server_app(**params)
 
-    app.listen(params['listen_port'])
+    app.listen(params['listen_port'], ssl_options=params['ssl'])
 
     IOLoop.instance().start()
 
diff --git a/jupytab-server/tests/config/example.cert b/jupytab-server/tests/config/example.cert
new file mode 100644
index 0000000..4365fd2
--- /dev/null
+++ b/jupytab-server/tests/config/example.cert
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDBzCCAe+gAwIBAgIJAK8X55pvCWVBMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV
+BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0yMDAzMjMxMTM0MjhaFw0zMDAzMjExMTM0
+MjhaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAOoF0nX1ZfQ686G+G7jexm9pz39GRYnlPRg5g/oj9MJl
+fpvkWdh3bBEmitsOpZDV8AgHKJlZD6rR6DX9w+Ay0g/QCd9lWKlVNusod8qyu2m2
+BM+oFbgex7JKObnd+CZq6Fe5/lWGDKqDkrTTijJes8QfkySEgevdYRUekgCT+ydI
+nldZHRnIdckRp3LBFoGdIQkZqtVKht+ECQotSeACW6EE4a88cY+ezupi6AG2I3kM
+Bm4UCUdD6x/q4ia6Cj7PKlg0jENdYBaOb6ZyPMKtyzYOikAWUrmA4txFc0uavgpZ
+rIspgnR/alZMiVjUPf4l/23cac/epD6li1aQJi0sU40CAwEAAaNQME4wHQYDVR0O
+BBYEFMIRQx8EBLmtn8sFy/XM3Ku23fzAMB8GA1UdIwQYMBaAFMIRQx8EBLmtn8sF
+y/XM3Ku23fzAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAOeLHtKp
+mzjLwzY1HZiLF/xKLYAeSNfQaFEso1BVLezswmMqh5UPQoWtSY6SexqkbGwewt16
+oYgI703Zs8SqXmBXTvVPbZ+GKviFat7sWCTZmQHBDjr46XZqbYvc+hbHNsl5Gu00
+uujR9GjYnogtvjARWmwLNRp0XCKOytGd2/ggnHV4o/92HoxdFBRp0qcpQJtu9gkd
+C/f7ZEx5vtnR70a+aQ4CpMdhoFJTf5pb5iwoHDn3vm0N1yZicKT0IXbEs4FcBAiU
+T6bc06cAnTzeIC/lbmTfcrpHw3J3JuG/Af3X1iIptJuYM0kcJOXPd2VU3hFu8xPW
+qCUBbTRSF+z6fxk=
+-----END CERTIFICATE-----
\ No newline at end of file
diff --git a/jupytab-server/tests/config/example.key b/jupytab-server/tests/config/example.key
new file mode 100644
index 0000000..4f1b774
--- /dev/null
+++ b/jupytab-server/tests/config/example.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEA6gXSdfVl9Drzob4buN7Gb2nPf0ZFieU9GDmD+iP0wmV+m+RZ
+2HdsESaK2w6lkNXwCAcomVkPqtHoNf3D4DLSD9AJ32VYqVU26yh3yrK7abYEz6gV
+uB7Hsko5ud34JmroV7n+VYYMqoOStNOKMl6zxB+TJISB691hFR6SAJP7J0ieV1kd
+Gch1yRGncsEWgZ0hCRmq1UqG34QJCi1J4AJboQThrzxxj57O6mLoAbYjeQwGbhQJ
+R0PrH+riJroKPs8qWDSMQ11gFo5vpnI8wq3LNg6KQBZSuYDi3EVzS5q+ClmsiymC
+dH9qVkyJWNQ9/iX/bdxpz96kPqWLVpAmLSxTjQIDAQABAoIBAQCCzcRICGT3MOgy
+VIc8OtChP3wqQIXnwIj4fFVnQCezbHVq/yS02HM/1tIwBKzIGrwyUIYByIT4TqFD
+ZFbSfrVo/zg1dHktFKNAp3rlgic8u+9Ofj29jv7BiblgSVBFcOXy+tPMy8NSn34l
+skOBSeuiyJ8+/w17X16/JjonNo9f8aYvB3HrucemHb5l3pN01G1PyTte742E8L+9
+MAKeMobrbbkSdfNXS8OhPTKmAV3VmVr47IkZL4DAa2K1uHTI6Zi2drx23q5eOe6v
+2T6e7Kj7pPNvUgUb2N3CGr6ATBtHT+uoxNYmLEJVLCSCxjbnXgOlpyB1RxtxSt5s
+93s2yd8FAoGBAP7lVk+zd/Rk4VwOpFEXC15kvKn3r2Iq3VWwFPOQ4s7WsGbN4jBd
+ZN53OMM0LeeRFqLOhqYD9xkV/XoA33wXdeVnv+pd+DXEtciPh2zdbnSti8kipqMN
+WFxqSGtGyCc0P1foOvkVa2r+p9ihnhTW6k8e03wWNSZ9py8+X0g0vrFfAoGBAOsJ
+VopQkzH3akLwArx9Lk3o4gRBAzxS4Ok4e0iIdr02/AkatT2oggcAid4ckPpW0VEh
+IbT9XicVc10cso5P1ozCyCdjbUcFyX63wr46ItwspyEqJTgyL6gEzwj2Rmoc/nXQ
+Rwbr/JpeKQ6Q/iRq+zuLlfja868wRWo7zjbpo8aTAoGBALFpkLSytqg9WvoHGulx
+/7C4rvQieEj8isesYjjRPHw4w9kaLff52U5abwC3HchSnQ2+b8u3cNJeEupLF0I4
+1g9RMiv/MdbCzsAE3n6wdMPzUxsw6gkNLdZNB5DbWE6pN/mIoxthhD2Zd9v5SZ05
+pSZiz1JL5ryesrHYWNtaEuxDAoGBAIxReN7+l8Ie6cuoqpmJSpmszTKo9ZuQB0J1
+O/Tjs6/nIbT1wvpanbY8dhKqj0tFhZWf6BW7pfhDcCpItbkMpRRIPWJ2k4jxRYhn
+gNY8sw8rgWPlW28fVyBCLrA1B3jWcnw3qg/R1275hB10JqXrUK4N+a0mWpFeijKQ
+Hd7ewa4NAoGAXsZSWXG8etwXTc8/i4f0oNItGxIcHdyYJaLTxjDHWyXbbd0rkyhU
+PPenULrwLRBpTU5BytLpWjnJLmEj4hbSrFnr9M/o3O8ccBo5ghYQJZYfO/k5ylp3
+liQNI7MYUdZLF0D0csouFMnRn6kA2zGkaaImH+EnIEzZxO1XSLJExfY=
+-----END RSA PRIVATE KEY-----
\ No newline at end of file
diff --git a/jupytab-server/tests/config/ssl_bad_file.ini b/jupytab-server/tests/config/ssl_bad_file.ini
new file mode 100644
index 0000000..eee4736
--- /dev/null
+++ b/jupytab-server/tests/config/ssl_bad_file.ini
@@ -0,0 +1,4 @@
+[main]
+ssl_enabled = true
+ssl_key = config/eXample.key
+ssl_cert = config/eXample.cert
\ No newline at end of file
diff --git a/jupytab-server/tests/config/ssl_disabled.ini b/jupytab-server/tests/config/ssl_disabled.ini
new file mode 100644
index 0000000..38cd08f
--- /dev/null
+++ b/jupytab-server/tests/config/ssl_disabled.ini
@@ -0,0 +1,4 @@
+[main]
+ssl_enabled = false
+ssl_key = config/example.key
+ssl_cert = config/example.cert
\ No newline at end of file
diff --git a/jupytab-server/tests/config/ssl_none.ini b/jupytab-server/tests/config/ssl_none.ini
new file mode 100644
index 0000000..9280d27
--- /dev/null
+++ b/jupytab-server/tests/config/ssl_none.ini
@@ -0,0 +1 @@
+[main]
\ No newline at end of file
diff --git a/jupytab-server/tests/config/ssl_ok.ini b/jupytab-server/tests/config/ssl_ok.ini
new file mode 100644
index 0000000..c577e4d
--- /dev/null
+++ b/jupytab-server/tests/config/ssl_ok.ini
@@ -0,0 +1,4 @@
+[main]
+ssl_enabled = true
+ssl_key = config/example.key
+ssl_cert = config/example.cert
\ No newline at end of file
diff --git a/jupytab-server/tests/test_config.py b/jupytab-server/tests/test_config.py
new file mode 100644
index 0000000..4d00422
--- /dev/null
+++ b/jupytab-server/tests/test_config.py
@@ -0,0 +1,25 @@
+import os
+from configparser import ConfigParser
+
+import pytest
+
+from jupytab_server.jupytab import config_ssl
+
+dir_path = os.path.dirname(os.path.realpath(__file__))
+os.chdir(dir_path)
+
+
+def config(config_file):
+    config = ConfigParser()
+    config.optionxform = str
+    config.read(config_file)
+    return config
+
+
+def test_ssl():
+    with pytest.raises(FileNotFoundError):
+        config_ssl(config('config/ssl_bad_file.ini'))
+    assert config_ssl(config('config/ssl_disabled.ini')) is None
+    assert config_ssl(config('config/ssl_none.ini')) is None
+    assert config_ssl(config('config/ssl_ok.ini')) == {'certfile': 'config/example.cert',
+                                                       'keyfile': 'config/example.key'}