tornadoweb / tornado

Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed.
http://www.tornadoweb.org/
Apache License 2.0
21.69k stars 5.5k forks source link

Redirect HTTP to HTTPS (for Heroku) #2597

Open Lucaszw opened 5 years ago

Lucaszw commented 5 years ago

I am running an instance of JupyterLab on Heroku. https://github.com/heroku/heroku-jupyterlab

Heroku handles the SSL certificate but requires the WebServer on the app level to handle the redirecting:

https://help.heroku.com/J2R1S4T8/can-heroku-force-an-application-to-use-ssl-tls

This seems to be possible with Django & Flask; but I can't seem to find any options for configuring Tornado to do the same (which is used to power JupyterLab).

It would be nice to have an option similar to in Rails and Django like config.force_ssl = true etc.

qigezhao commented 5 years ago

Hi there. I got some ideas to fit your case. By default, HTTP binds port 80, HTTPS binds port 443. Method I: better, easier and faster use nginx, convert http to https, and of course, you can configure nginx to serve SSL as well. Method II: you can build 2 app, app1 binds port 80, and get user's URI, then redirect the request to app2, with the same URI; app2 binds port 443, handle SSL requests. Have fun.

ploxiln commented 5 years ago

In heroku, all requests come in to the application as plain http, but with the header X-Forwarded-Proto to know whether the original request was http or https.

Tornado is more of a toolkit than a solution, in my humble opinion - there's a couple of ways to do this in the application reasonably easily, and it should be done there. And in fact, there are recurring requests for this to work in a couple different ways for a few different deployment scenarios:

https://github.com/ipython/ipython/issues/1460/ https://github.com/tornadoweb/tornado/issues/523 https://github.com/jupyterhub/jupyterhub-deploy-docker/issues/40 https://github.com/jupyterhub/jupyterhub-deploy-docker/issues/74

It looks like jupyter has an option NotebookApp.trust_xheaders which defaults to False https://jupyter-notebook.readthedocs.io/en/stable/config.html

bdarnell commented 5 years ago

The routing framework doesn't currently know anything about http vs https because in non-proxied deployments, they're handled by entirely separate servers that can have their own rules. But in the proxied case with xheaders enabled, this becomes useful. It's pretty simple to do with the (relatively) new routing framework:

from tornado.httpclient import AsyncHTTPClient
from tornado.ioloop import IOLoop
from tornado.routing import Matcher
from tornado.web import Application, RequestHandler

class ProtocolMatcher(Matcher):
    def __init__(self, protocol):
        self.protocol = protocol

    def match(self, request):
        if request.protocol == self.protocol:
            return {}
        return None

class HTTPSRedirectHandler(RequestHandler):
    def get(self):
        # This is different from tornado.web.RedirectHandler because
        # the substitution capabilities in that class aren't quite
        # what we need (we don't get path extraction from ProtocolMatcher).
        self.redirect("https://" + self.request.host + self.request.uri, permanent=True)

class MyHandler(RequestHandler):
    def get(self):
        self.write("hello %s" % self.request.protocol)

async def main():
    app = Application(
        [(ProtocolMatcher("http"), HTTPSRedirectHandler), ("/", MyHandler)]
    )
    app.listen(8080, address="localhost", xheaders=True)

    client = AsyncHTTPClient()
    resp = await client.fetch(
        "http://localhost:8080/", follow_redirects=False, raise_error=False
    )
    assert resp.code == 301
    assert resp.headers["Location"] == "https://localhost:8080/"
    resp = await client.fetch(
        "http://localhost:8080/",
        follow_redirects=False,
        raise_error=False,
        headers={"X-Forwarded-Proto": "http"},
    )
    assert resp.code == 301
    assert resp.headers["Location"] == "https://localhost:8080/"

    resp = await client.fetch(
        "http://localhost:8080/",
        follow_redirects=False,
        headers={"X-Forwarded-Proto": "https"},
    )
    assert resp.code == 200
    assert resp.body == b"hello https"

if __name__ == "__main__":
    IOLoop.current().run_sync(main)
Lucaszw commented 5 years ago

Thanks for the recommendations! I ended up going with the nginx heroku buildpack and used the following config:

daemon off;

worker_processes <%= ENV['NGINX_WORKERS'] || 4 %>;

events {
    use epoll;
    accept_mutex on;
    worker_connections 1024;
}

http {
    # Buffers
    client_body_buffer_size 10K;
    client_header_buffer_size 1k;
    client_max_body_size 8m;
    large_client_header_buffers 2 1k;

    # Timeouts
    client_body_timeout 12;
    client_header_timeout 12;
    keepalive_timeout 15;
    send_timeout 10;

    # Gzip
    gzip on;
    gzip_comp_level 2;
    gzip_min_length 1000;
    gzip_proxied expired no-cache no-store private auth;
    gzip_types text/plain application/x-javascript text/xml text/css application/xml;

    # Logging
    log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id';
    access_log off;
    error_log logs/nginx/error.log;

    # Other
    server_tokens off;
    include mime.types;
    sendfile on;
    default_type application/octet-stream;

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    upstream ml {
        server 127.0.0.1:3000;
    }

    server {
        listen <%= ENV["PORT"] %>;
        server_name _;

        if ($http_x_forwarded_proto != "https") {
            return 301 https://$host$request_uri;
        }

        location / {
            proxy_pass http://127.0.0.1:3000;
            proxy_redirect off;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;

                        proxy_http_version 1.1;
                        proxy_set_header Upgrade $http_upgrade;
                        proxy_set_header Connection $connection_upgrade;
                        proxy_set_header Referer  http://localhost;
                        proxy_set_header Origin "";
        }
    }
}

with the following Procfile

web: bin/start-nginx jupyter lab --config=./config.py --ip 127.0.0.1 --port 3000
cristianrat commented 5 years ago

@Lucaszw solution works for me as we :+1: