diff --git a/readme.md b/readme.md index 688f46d..a28794c 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,7 @@ server { location /auth { proxy_pass http://127.0.0.1:8000; # This is the TOTP Server proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $request_addr; # So we can log attempts by IP } # This ensures that if the TOTP server returns 401 we redirect to login @@ -43,14 +44,20 @@ server { # The rest of the server definition, including SSL and whatnot ``` +If your application already uses /auth then you can change that, as long as you modify the `location` parameter in totpauth.conf during install. ## Additional assembly required: -1. You need to run `main.py` with Python3.5+ in a tmux session or something like supervisord. -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.) +1. Create totpauth user, or other user if desired +2. Copy totpauth.py to /opt/totpauth/ or your preferred location, make sure totpauth user can read and execute it +3. Copy totpauth.service to /etc/systemd/system/, update if you're using a different path or totpauth.py location +4. Create /var/log/totpauth/ or some other place to store logs, and ensure the totpauth user can write to it +5. Copy totpauth.conf.example to /etc/totpauth/totpauth.conf, modify it as desired, and make sure totpauth user can read it +6. Generate a TOTP secret using the command below and store it in `/etc/totpauth/secret` (or update totpauth.conf with its correct location), making sure that ONLY the totpauth user can read it ``` python3 -c "import pyotp; print(pyotp.random_base32())" > .totp_secret ``` +7. Import the TOTP secret into your two factor auth manager of choice (Google Authenticator, Duo, etc.) ## FAQ diff --git a/totpauth.conf.example b/totpauth.conf.example new file mode 100644 index 0000000..377a104 --- /dev/null +++ b/totpauth.conf.example @@ -0,0 +1,30 @@ +[TOTP] + +logfile=/var/log/totpauth/totpauth.log +# Log level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL +loglevel=INFO + +secretfile=/etc/totpauth/secret + +# The totp_window allows codes earlier or later, to account for clock drift and entry time +# For example, a window of 3 will accept a code up to 90 seconds old (assuming 30 second codes) +totp_window=3 + +# These should match the nginx config +port=8000 +location=/auth + +# This should not collide with any cookies your web app sets +cookie_name=totp_token +secure_cookie=true + +# lifetime is in seconds +token_lifetime=86400 + +# A few parameters to make the TOTP landing page your own: +title=Website TOTP Auth + +# This is inserted into the body in a -
+

{title}

+

+ +

+

-""" +""".format(title=TITLE, location=LOCATION, style=STYLE) class TokenManager(object): """Who needs a database when you can just store everything in memory?""" @@ -52,20 +75,20 @@ def invalidate(self, t): class AuthHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): - if self.path == '/auth/check': + if self.path == LOCATION + '/check': # Check if they have a valid token cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) - if 'token' in cookie and TOKEN_MANAGER.is_valid(cookie['token'].value): + if COOKIE_NAME in cookie and TOKEN_MANAGER.is_valid(cookie[COOKIE_NAME].value): self.send_response(200) self.end_headers() return - # Otherwise return 401, which will be redirected to '/auth/login' upstream + # Otherwise return 401, which will be redirected to LOCATION + '/login' upstream self.send_response(401) self.end_headers() return - if self.path == '/auth/login': + if self.path == LOCATION + '/login': # Render out the login form self.send_response(200) self.send_header('Content-type', 'text/html') @@ -73,18 +96,18 @@ def do_GET(self): self.wfile.write(bytes(FORM, 'UTF-8')) return - if self.path == '/auth/logout': + if self.path == LOCATION + '/logout': # Invalidate any tokens cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) - if 'token' in cookie: - TOKEN_MANAGER.invalidate(cookie['token'].value) + if COOKIE_NAME in cookie: + TOKEN_MANAGER.invalidate(cookie[COOKIE_NAME].value) # This just replaces the token with garbage self.send_response(302) cookie = http.cookies.SimpleCookie() - cookie["token"] = '***' - cookie["token"]["path"] = '/' - cookie["token"]["secure"] = True + cookie[COOKIE_NAME] = '***' + cookie[COOKIE_NAME]["path"] = '/' + cookie[COOKIE_NAME]["secure"] = SECURE_COOKIE self.send_header('Set-Cookie', cookie.output(header='')) self.send_header('Location', '/') self.end_headers() @@ -95,23 +118,34 @@ def do_GET(self): self.end_headers() def do_POST(self): - if self.path == '/auth/login': + if self.path == LOCATION + '/login': + ip=self.headers.get("X-Real-IP") + logging.debug("Auth attempt from " + ip) # Rate limit login attempts to once per second global LAST_LOGIN_ATTEMPT if time.time() - LAST_LOGIN_ATTEMPT < 1.0: self.send_response(429) self.end_headers() self.wfile.write(bytes('Slow down. Hold your horses', 'UTF-8')) + logging.warning("Excessive login attempts from " + ip) return LAST_LOGIN_ATTEMPT = time.time() - # Check the TOTP Secret params = self.parse_POST() - if (params.get(b'token') or [None])[0] == bytes(pyotp.TOTP(SECRET).now(), 'UTF-8'): + + # Check the TOTP Secret + + submit_code = (params.get(b'token') or [None])[0].decode('utf-8') + # The following statement can be uncommented for debugging, but should not be used on a + # live system to prevent log injection attacks + #logging.debug("Submitted code is " + submit_code) + + if (pyotp.TOTP(SECRET).verify(otp=submit_code, valid_window=WINDOW)): + logging.info("Successful auth from " + ip) cookie = http.cookies.SimpleCookie() - cookie["token"] = TOKEN_MANAGER.generate() - cookie["token"]["path"] = "/" - cookie["token"]["secure"] = True + cookie[COOKIE_NAME] = TOKEN_MANAGER.generate() + cookie[COOKIE_NAME]["path"] = "/" + cookie[COOKIE_NAME]["secure"] = SECURE_COOKIE self.send_response(302) self.send_header('Set-Cookie', cookie.output(header='')) @@ -121,8 +155,9 @@ def do_POST(self): # Otherwise redirect back to the login page else: + logging.warning("Failed auth from " + ip) self.send_response(302) - self.send_header('Location', '/auth/login') + self.send_header('Location', LOCATION + '/login') self.end_headers() return @@ -142,10 +177,17 @@ def parse_POST(self): postvars = {} return postvars + def log_message(self, format, *args): + logging.debug(format%args) + socketserver.TCPServer.allow_reuse_address = True httpd = socketserver.TCPServer(("", PORT), AuthHandler) try: - print("serving at port", PORT) + print("Listening on port " + str(PORT)) + logging.info("Listening on port " + str(PORT)) httpd.serve_forever() finally: httpd.server_close() + + + diff --git a/totpauth.service b/totpauth.service new file mode 100644 index 0000000..5483956 --- /dev/null +++ b/totpauth.service @@ -0,0 +1,9 @@ +[Unit] +Description=TOTP Auth service for NGINX + +[Service] +User=totpauth +ExecStart=/usr/bin/python3 /opt/totpauth/totpauth.py + +[Install] +WantedBy=multi-user.target