Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
30 changes: 30 additions & 0 deletions totpauth.conf.example
Original file line number Diff line number Diff line change
@@ -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 <style> tag for aesthetics
style=body{margin:40px auto;max-width:550px;line-height:1.6;font-size:18px;color:#444;padding:0 10px;}h1,h2,h3{line-height:1.2;}
# Shamelessly stolen from bettermotherfuckingwebsite.com

92 changes: 67 additions & 25 deletions main.py → totpauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,49 @@
import time
from urllib.parse import parse_qs
from cgi import parse_header, parse_multipart

import configparser
import pyotp
import logging
config = configparser.ConfigParser()
config.read('/etc/totpauth/totpauth.conf')
conf = config['TOTP']
LOGFILE = conf.get('logfile', '/var/log/totpauth/totpauth.log')
LOGLEVEL = conf.get('loglevel','INFO')
SECRETFILE = conf.get('secretfile', '/etc/totp_secret')
WINDOW = conf.getint('totp_window', 1)
PORT = conf.getint('port', 8000)
TOKEN_LIFETIME = conf.getint('token_lifetime', 60 * 60 * 24)
LOCATION = conf.get('location', '/auth')
COOKIE_NAME = conf.get('cookie_name', 'totp_token')
TITLE = conf.get('title', "Website TOTP Auth")
STYLE = conf.get('style', "")
SECURE_COOKIE = conf.getboolean('secure_cookie', True)
logging.basicConfig(filename=LOGFILE, level=LOGLEVEL, format='%(asctime)s - %(levelname)s:%(message)s', datefmt='%Y-%m-%d %H:%M:%S')

print('Logging to ' + LOGFILE)

PORT = 8000
TOKEN_LIFETIME = 60 * 60 * 24
LAST_LOGIN_ATTEMPT = 0
SECRET = open('.totp_secret').read().strip()
SECRET = open(SECRETFILE).read().strip()
FORM = """
<html>
<head>
<title>Please Log In</title>
<title>{title}</title>
<style type="text/css">
{style}
</style>
</head>
<body>
<form action="/auth/login" method="POST">
<h1>{title}</h1>
<p>
<form action="{location}/login" method="POST">
<label for="token">Enter one-time code:</label>
<input type="text" name="token">
<input type="submit" value="Submit">
</form>
</p>
</body>
</html>
"""
""".format(title=TITLE, location=LOCATION, style=STYLE)

class TokenManager(object):
"""Who needs a database when you can just store everything in memory?"""
Expand Down Expand Up @@ -52,39 +75,39 @@ 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')
self.end_headers()
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()
Expand All @@ -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=''))
Expand All @@ -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

Expand All @@ -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()



9 changes: 9 additions & 0 deletions totpauth.service
Original file line number Diff line number Diff line change
@@ -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