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} + -
+

{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