├── readme.md └── main.py /readme.md: -------------------------------------------------------------------------------- 1 | # Super Basic TOTP `auth_request` Server for nginx 2 | 3 | ## What is this for? 4 | 5 | Have you ever wanted to add more security to a web application without modifying the web application itself? Take for example Jupter Notebook/Lab, which allows you to run arbitrary code from a web browser. It supports a built-in password / token-based authentication. Hopefully you're using a unique password, but if you're following proper security practices it's generally a good idea to protect stuff with "something you know and something you have." Chances are that if you've gotten this far you don't need me to convince you of the merits of two factor authentication. 6 | 7 | ## How does it work? 8 | 9 | I use nginx in front of a variety of web services to handle SSL termination (using letsencrypt, which is amazing and you should also use). Nginx has a handy module called auth_request that you can use to specify an endpoint to check if a user is authenticated. If the endpoint returns 200, the parent request is allowed to succeed, otherwise a 401 error is returned. You can set up nginx to then redirect the user to a login page where they can do whatever they need to assert proof of identity. 10 | 11 | In this case, the auth endpoint is reverse proxied to the simple script in this repo, which does things like token checking and presenting a login form. 12 | 13 | ## Example Configuration 14 | 15 | In something like `/etc/nginx/sites-enabled/default` 16 | 17 | ``` 18 | server { 19 | server_name jupyter.example.com; 20 | 21 | location /auth { 22 | proxy_pass http://127.0.0.1:8000; # This is the TOTP Server 23 | proxy_set_header X-Original-URI $request_uri; 24 | } 25 | 26 | # This ensures that if the TOTP server returns 401 we redirect to login 27 | error_page 401 = @error401; 28 | location @error401 { 29 | return 302 /auth/login; 30 | } 31 | 32 | location / { 33 | auth_request /auth/check; 34 | proxy_pass http://127.0.0.1:8888; # This is Jupyter 35 | 36 | # This is needed for Jupyter to proxy websockets correctly, 37 | # it's unrelated to auth but handy to have written down here 38 | # for reference anyhow... 39 | proxy_http_version 1.1; 40 | proxy_set_header Upgrade $http_upgrade; 41 | proxy_set_header Connection $connection_upgrade; 42 | } 43 | 44 | # The rest of the server definition, including SSL and whatnot 45 | ``` 46 | 47 | ## Additional assembly required: 48 | 49 | 1. You need to run `main.py` with Python3.5+ in a tmux session or something like supervisord. 50 | 2. You should generate a TOTP secret (i.e. `import pyotp; print(pyotp.random_base32())`) and store it in `.totp_secret` alongside `main.py` and also your two factor auth manager of choice (Google Authenticator, Duo, etc.) 51 | ``` 52 | python3 -c "import pyotp; print(pyotp.random_base32())" > .totp_secret 53 | ``` 54 | 55 | ## FAQ 56 | 57 | **Wait, this checks the TOTP secret before you enter a password?** 58 | 59 | Yep, it feels kinda backwards, but I only have one login anyhow and I've rate-limited TOTP checks, so you can't hammer auth to figure out the TOTP secret. 60 | 61 | **What about CSRF attacks?** 62 | 63 | In my case Jupyter already prevents CSRF attacks and the only thing you could do (as far as I can tell) with CSRF attacks on the auth server is log a user out, so I haven't bothered. 64 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import http.cookies 2 | import http.server 3 | import socketserver 4 | import random 5 | import time 6 | from urllib.parse import parse_qs 7 | from cgi import parse_header, parse_multipart 8 | 9 | import pyotp 10 | 11 | PORT = 8000 12 | TOKEN_LIFETIME = 60 * 60 * 24 13 | LAST_LOGIN_ATTEMPT = 0 14 | SECRET = open('.totp_secret').read().strip() 15 | FORM = """ 16 | 17 | 18 | Please Log In 19 | 20 | 21 |
22 | 23 | 24 |
25 | 26 | 27 | """ 28 | 29 | class TokenManager(object): 30 | """Who needs a database when you can just store everything in memory?""" 31 | 32 | def __init__(self): 33 | self.tokens = {} 34 | self.random = random.SystemRandom() 35 | 36 | def generate(self): 37 | t = '%064x' % self.random.getrandbits(8*32) 38 | self.tokens[t] = time.time() 39 | return t 40 | 41 | def is_valid(self, t): 42 | try: 43 | return time.time() - self.tokens.get(t, 0) < TOKEN_LIFETIME 44 | except Exception: 45 | return False 46 | 47 | def invalidate(self, t): 48 | if t in self.tokens: 49 | del self.tokens[t] 50 | 51 | TOKEN_MANAGER = TokenManager() 52 | 53 | class AuthHandler(http.server.BaseHTTPRequestHandler): 54 | def do_GET(self): 55 | if self.path == '/auth/check': 56 | # Check if they have a valid token 57 | cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) 58 | if 'token' in cookie and TOKEN_MANAGER.is_valid(cookie['token'].value): 59 | self.send_response(200) 60 | self.end_headers() 61 | return 62 | 63 | # Otherwise return 401, which will be redirected to '/auth/login' upstream 64 | self.send_response(401) 65 | self.end_headers() 66 | return 67 | 68 | if self.path == '/auth/login': 69 | # Render out the login form 70 | self.send_response(200) 71 | self.send_header('Content-type', 'text/html') 72 | self.end_headers() 73 | self.wfile.write(bytes(FORM, 'UTF-8')) 74 | return 75 | 76 | if self.path == '/auth/logout': 77 | # Invalidate any tokens 78 | cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) 79 | if 'token' in cookie: 80 | TOKEN_MANAGER.invalidate(cookie['token'].value) 81 | 82 | # This just replaces the token with garbage 83 | self.send_response(302) 84 | cookie = http.cookies.SimpleCookie() 85 | cookie["token"] = '***' 86 | cookie["token"]["path"] = '/' 87 | cookie["token"]["secure"] = True 88 | self.send_header('Set-Cookie', cookie.output(header='')) 89 | self.send_header('Location', '/') 90 | self.end_headers() 91 | return 92 | 93 | # Otherwise return 404 94 | self.send_response(404) 95 | self.end_headers() 96 | 97 | def do_POST(self): 98 | if self.path == '/auth/login': 99 | # Rate limit login attempts to once per second 100 | global LAST_LOGIN_ATTEMPT 101 | if time.time() - LAST_LOGIN_ATTEMPT < 1.0: 102 | self.send_response(429) 103 | self.end_headers() 104 | self.wfile.write(bytes('Slow down. Hold your horses', 'UTF-8')) 105 | return 106 | LAST_LOGIN_ATTEMPT = time.time() 107 | 108 | # Check the TOTP Secret 109 | params = self.parse_POST() 110 | if (params.get(b'token') or [None])[0] == bytes(pyotp.TOTP(SECRET).now(), 'UTF-8'): 111 | cookie = http.cookies.SimpleCookie() 112 | cookie["token"] = TOKEN_MANAGER.generate() 113 | cookie["token"]["path"] = "/" 114 | cookie["token"]["secure"] = True 115 | 116 | self.send_response(302) 117 | self.send_header('Set-Cookie', cookie.output(header='')) 118 | self.send_header('Location', '/') 119 | self.end_headers() 120 | return 121 | 122 | # Otherwise redirect back to the login page 123 | else: 124 | self.send_response(302) 125 | self.send_header('Location', '/auth/login') 126 | self.end_headers() 127 | return 128 | 129 | # Otherwise return 404 130 | self.send_response(404) 131 | self.end_headers() 132 | 133 | def parse_POST(self): 134 | """Lifted from https://stackoverflow.com/questions/4233218/""" 135 | ctype, pdict = parse_header(self.headers['content-type']) 136 | if ctype == 'multipart/form-data': 137 | postvars = parse_multipart(self.rfile, pdict) 138 | elif ctype == 'application/x-www-form-urlencoded': 139 | length = int(self.headers['Content-Length']) 140 | postvars = parse_qs( self.rfile.read(length), keep_blank_values=1) 141 | else: 142 | postvars = {} 143 | return postvars 144 | 145 | socketserver.TCPServer.allow_reuse_address = True 146 | httpd = socketserver.TCPServer(("", PORT), AuthHandler) 147 | try: 148 | print("serving at port", PORT) 149 | httpd.serve_forever() 150 | finally: 151 | httpd.server_close() 152 | --------------------------------------------------------------------------------