From 1e112f844d0b5cd9b5f8f51474f2c75cbfd7956a Mon Sep 17 00:00:00 2001
From: Gordon Dexter
Date: Tue, 20 Sep 2022 13:50:18 -0400
Subject: [PATCH 1/5] Update to add logging, conf file, service file for
systemd users, and a little better aesthetics
---
readme.md | 10 ++++-
totpauth.conf.example | 9 +++++
main.py => totpauth.py | 84 +++++++++++++++++++++++++++++-------------
totpauth.service | 9 +++++
4 files changed, 85 insertions(+), 27 deletions(-)
create mode 100644 totpauth.conf.example
rename main.py => totpauth.py (61%)
create mode 100644 totpauth.service
diff --git a/readme.md b/readme.md
index 688f46d..3930141 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,19 @@ 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 execute it
+3. Copy totpauth.service to /etc/systemd/system, update if you're using a different path or totpauth.py location
+4. Copy totpauth.conf.example to /etc/totpauth/totpauth.conf, modify it as desired, and make sure totpauth user can read it
+5. 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
```
+6. 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..aea3ba7
--- /dev/null
+++ b/totpauth.conf.example
@@ -0,0 +1,9 @@
+[TOTP]
+logfile=/var/log/totpauth/totpauth.log
+secretfile=/etc/totpauth/secret
+port=8000
+token_lifetime=86400
+location=/auth
+title=Website TOTP Auth
+style=body{margin:40px auto;max-width:550px;line-height:1.6;font-size:18px;color:#222;padding:0 10px;}h1,h2,h3{line-height:1.2;}
+
diff --git a/main.py b/totpauth.py
similarity index 61%
rename from main.py
rename to totpauth.py
index eb57ea7..cd15807 100644
--- a/main.py
+++ b/totpauth.py
@@ -5,26 +5,50 @@
import time
from urllib.parse import parse_qs
from cgi import parse_header, parse_multipart
-
+import configparser
import pyotp
-
-PORT = 8000
-TOKEN_LIFETIME = 60 * 60 * 24
+import logging
+config = configparser.ConfigParser()
+config.read('/etc/totpauth/totpauth.conf')
+conf = config['TOTP']
+LOGFILE = conf.get('logfile', '/var/log/totpauth/totpauth.log')
+SECRETFILE = conf.get('secretfile', '/etc/totp_secret')
+PORT = conf.getint('port', 8000)
+TOKEN_LIFETIME = conf.getint('token_lifetime', 60 * 60 * 24)
+LOCATION = conf.get('location', '/auth')
+TITLE = conf.get('title', "Website TOTP Auth")
+STYLE = conf.get('style', "body{margin:40px auto;max-width:600px;line-height:1.6;font-size:18px;color:#222;padding:0 10px;}h1,h2,h3{line-height:1.2;}")
+SECURE_COOKIE = conf.getbool('secure_cookie', True)
+
+logging.basicConfig(filename=LOGFILE, level=logging.DEBUG, format='%(asctime)s - %(levelname)s:%(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+
+print('Logging to ' + LOGFILE)
+
+#PORT = 8123
+TOKEN_NAME = 'token' # Changing this seems to break things for some reason
+#TOKEN_LIFETIME = 60 * 60 * 24
LAST_LOGIN_ATTEMPT = 0
-SECRET = open('.totp_secret').read().strip()
+SECRET = open(SECRETFILE).read().strip()
FORM = """
-Please Log In
+{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 +76,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 TOKEN_NAME in cookie and TOKEN_MANAGER.is_valid(cookie[TOKEN_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 +97,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 TOKEN_NAME in cookie:
+ TOKEN_MANAGER.invalidate(cookie[TOKEN_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[TOKEN_NAME] = '***'
+ cookie[TOKEN_NAME]["path"] = '/'
+ cookie[TOKEN_NAME]["secure"] = SECURE_COOKIE
self.send_header('Set-Cookie', cookie.output(header=''))
self.send_header('Location', '/')
self.end_headers()
@@ -95,7 +119,7 @@ def do_GET(self):
self.end_headers()
def do_POST(self):
- if self.path == '/auth/login':
+ if self.path == LOCATION + '/login':
# Rate limit login attempts to once per second
global LAST_LOGIN_ATTEMPT
if time.time() - LAST_LOGIN_ATTEMPT < 1.0:
@@ -107,11 +131,13 @@ def do_POST(self):
# Check the TOTP Secret
params = self.parse_POST()
- if (params.get(b'token') or [None])[0] == bytes(pyotp.TOTP(SECRET).now(), 'UTF-8'):
+ ip=self.headers.get("X-Real-IP")
+ if (params.get(TOKEN_NAME.encode('utf-8')) or [None])[0] == bytes(pyotp.TOTP(SECRET).now(), 'UTF-8'):
+ logging.info("Successful auth from " + ip)
cookie = http.cookies.SimpleCookie()
- cookie["token"] = TOKEN_MANAGER.generate()
- cookie["token"]["path"] = "/"
- cookie["token"]["secure"] = True
+ cookie[TOKEN_NAME] = TOKEN_MANAGER.generate()
+ cookie[TOKEN_NAME]["path"] = "/"
+ cookie[TOKEN_NAME]["secure"] = True
self.send_response(302)
self.send_header('Set-Cookie', cookie.output(header=''))
@@ -121,8 +147,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 +169,17 @@ def parse_POST(self):
postvars = {}
return postvars
+ def log_message(self, format, *args):
+ logging.info(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
From d50150a645cbed3128fea727a055c4c09529dede Mon Sep 17 00:00:00 2001
From: Gordon Dexter
Date: Tue, 20 Sep 2022 13:50:18 -0400
Subject: [PATCH 2/5] Finishing touches, added logdir creation to readme
---
readme.md | 11 ++++++-----
totpauth.py | 2 +-
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/readme.md b/readme.md
index 3930141..a28794c 100644
--- a/readme.md
+++ b/readme.md
@@ -49,14 +49,15 @@ If your application already uses /auth then you can change that, as long as you
## Additional assembly required:
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 execute it
-3. Copy totpauth.service to /etc/systemd/system, update if you're using a different path or totpauth.py location
-4. Copy totpauth.conf.example to /etc/totpauth/totpauth.conf, modify it as desired, and make sure totpauth user can read it
-5. 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
+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
```
-6. Import the TOTP secret into your two factor auth manager of choice (Google Authenticator, Duo, etc.)
+7. Import the TOTP secret into your two factor auth manager of choice (Google Authenticator, Duo, etc.)
## FAQ
diff --git a/totpauth.py b/totpauth.py
index cd15807..56becdf 100644
--- a/totpauth.py
+++ b/totpauth.py
@@ -17,7 +17,7 @@
TOKEN_LIFETIME = conf.getint('token_lifetime', 60 * 60 * 24)
LOCATION = conf.get('location', '/auth')
TITLE = conf.get('title', "Website TOTP Auth")
-STYLE = conf.get('style', "body{margin:40px auto;max-width:600px;line-height:1.6;font-size:18px;color:#222;padding:0 10px;}h1,h2,h3{line-height:1.2;}")
+STYLE = conf.get('style', "body{margin:40px auto;max-width:600px;line-height:1.6;font-size:18px;color:#222;padding:0 10px;}h1,h2,h3{line-height:1.2;}") # Shamelessly stolen from bettermotherfuckingwebsite.com
SECURE_COOKIE = conf.getbool('secure_cookie', True)
logging.basicConfig(filename=LOGFILE, level=logging.DEBUG, format='%(asctime)s - %(levelname)s:%(message)s', datefmt='%Y-%m-%d %H:%M:%S')
From 1bee1276f56cf7d2856f2f3feba00f050d38c402 Mon Sep 17 00:00:00 2001
From: Gordon Dexter
Date: Tue, 20 Sep 2022 13:50:18 -0400
Subject: [PATCH 3/5] Typo fix
---
totpauth.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/totpauth.py b/totpauth.py
index 56becdf..e30e9c3 100644
--- a/totpauth.py
+++ b/totpauth.py
@@ -18,7 +18,7 @@
LOCATION = conf.get('location', '/auth')
TITLE = conf.get('title', "Website TOTP Auth")
STYLE = conf.get('style', "body{margin:40px auto;max-width:600px;line-height:1.6;font-size:18px;color:#222;padding:0 10px;}h1,h2,h3{line-height:1.2;}") # Shamelessly stolen from bettermotherfuckingwebsite.com
-SECURE_COOKIE = conf.getbool('secure_cookie', True)
+SECURE_COOKIE = conf.getboolean('secure_cookie', True)
logging.basicConfig(filename=LOGFILE, level=logging.DEBUG, format='%(asctime)s - %(levelname)s:%(message)s', datefmt='%Y-%m-%d %H:%M:%S')
From 13a539f0caf523880cba66a945255682c266373b Mon Sep 17 00:00:00 2001
From: Gordon Dexter
Date: Tue, 20 Sep 2022 17:06:50 -0400
Subject: [PATCH 4/5] Added token window, improved config file, fixed custom
cookie naming
---
totpauth.conf.example | 24 ++++++++++++++++++++--
totpauth.py | 46 +++++++++++++++++++++++++------------------
2 files changed, 49 insertions(+), 21 deletions(-)
diff --git a/totpauth.conf.example b/totpauth.conf.example
index aea3ba7..79eb718 100644
--- a/totpauth.conf.example
+++ b/totpauth.conf.example
@@ -1,9 +1,29 @@
[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
-token_lifetime=86400
location=/auth
+
+# This should not collide with any cookies your web app sets
+cookie_name=totp_token
+
+# lifetime is in seconds
+token_lifetime=86400
+
+# A few parameters to make the TOTP landing page your own:
title=Website TOTP Auth
-style=body{margin:40px auto;max-width:550px;line-height:1.6;font-size:18px;color:#222;padding:0 10px;}h1,h2,h3{line-height:1.2;}
+
+# This is inserted into the body in a