Dominik 2 months ago
parent
commit
676487ba92
9 changed files with 244 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 4 0
      README.md
  3. 18 0
      build/Dockerfile
  4. 158 0
      build/totp/main.py
  5. 24 0
      build/totp/new-secret.py
  6. 3 0
      build/totp/new-secret.sh
  7. 16 0
      docker-compose.yml
  8. 3 0
      new-secret.sh
  9. 16 0
      vdi.example.com_location

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+.env
+secret.qr

+ 4 - 0
README.md

@@ -1,5 +1,9 @@
 # Simple totp subrequest authentication
 
+## Setup
+- Run `new-secret.sh` to create a `.env` file with a random secret and a text file `secret.qr` with the corresponding qr-code for your TOTP mobile app.
+- Adapt the sample configuration `vdi.example.com_location` and copy it to `vhost.d` of your [reverse-proxy](https://github.com/nginx-proxy/nginx-proxy)
+
 ## Credits:
   - Based on [newhouseb/nginxwebauthn](https://github.com/newhouseb/nginxwebauthn)
 

+ 18 - 0
build/Dockerfile

@@ -0,0 +1,18 @@
+FROM alpine:3.16
+RUN  apk add tzdata python3 py3-pip && \
+     pip3 install --upgrade pyotp pyqrcode
+
+COPY --chown=1000:1000 totp/ /opt/totp
+
+USER 1000:1000
+
+WORKDIR /opt/totp/
+
+ENTRYPOINT ["python3"]
+
+CMD ["main.py"]
+
+HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
+            CMD wget -q -S localhost:8000 2>&1  | grep -q "404"
+
+EXPOSE 8000

+ 158 - 0
build/totp/main.py

@@ -0,0 +1,158 @@
+import http.cookies
+import http.server
+import socketserver
+import random
+import time
+import os
+from urllib.parse import parse_qs
+from cgi import parse_header, parse_multipart
+
+import pyotp
+
+PORT = 8000
+TOKEN_LIFETIME = 60 * 60 * 2
+LAST_LOGIN_ATTEMPT = 0
+#SECRET = open('.totp_secret').read().strip()
+SECRET = os.getenv('SECRET')
+SECURE_COOKIE = False
+FORM = """
+<html>
+<head>
+<title>Please Log In</title>
+</head>
+<body>
+<form action="/auth/login" method="POST">
+<input type="text" name="token">
+<input type="submit" value="Submit">
+</form>
+</body>
+</html>
+"""
+
+class TokenManager(object):
+    """Who needs a database when you can just store everything in memory?"""
+
+    def __init__(self):
+        self.tokens = {}
+        self.random = random.SystemRandom()
+
+    def generate(self):
+        t = '%064x' % self.random.getrandbits(8*32)
+        self.tokens[t] = time.time()
+        return t
+
+    def is_valid(self, t):
+        try:
+            return time.time() - self.tokens.get(t, 0) < TOKEN_LIFETIME
+        except Exception:
+            return False
+
+    def invalidate(self, t):
+        if t in self.tokens:
+            del self.tokens[t]
+
+TOKEN_MANAGER = TokenManager()
+
+class AuthHandler(http.server.BaseHTTPRequestHandler):
+    def do_GET(self):
+        if self.path == '/auth/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):
+                print('cookie ok')
+                self.send_response(200)
+                self.end_headers()
+                return
+
+            # Otherwise return 401, which will be redirected to '/auth/login' upstream
+            print('no valid cookie found')
+            self.send_response(401)
+            self.end_headers()
+            return
+
+        if self.path == '/auth/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':
+            # Invalidate any tokens
+            cookie = http.cookies.SimpleCookie(self.headers.get('Cookie'))
+            if 'token' in cookie:
+                TOKEN_MANAGER.invalidate(cookie['token'].value)
+
+            # This just replaces the token with garbage
+            self.send_response(302)
+            cookie = http.cookies.SimpleCookie()
+            cookie["token"] = '***'
+            cookie["token"]["path"] = '/'
+            cookie["token"]["secure"] = SECURE_COOKIE 
+            self.send_header('Set-Cookie', cookie.output(header=''))
+            self.send_header('Location', '/')
+            self.end_headers()
+            return
+
+        # Otherwise return 404
+        self.send_response(404)
+        self.end_headers()
+
+    def do_POST(self):
+        if self.path == '/auth/login':
+            # 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'))
+                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'):
+                print('otp ok')
+                cookie = http.cookies.SimpleCookie()
+                cookie["token"] = TOKEN_MANAGER.generate()
+                cookie["token"]["path"] = "/"
+                cookie["token"]["secure"] = SECURE_COOKIE 
+
+                self.send_response(302)
+                self.send_header('Set-Cookie', cookie.output(header=''))
+                self.send_header('Location', '/')
+                self.end_headers()
+                return
+
+            # Otherwise redirect back to the login page
+            else:
+                print('otp not ok')
+                self.send_response(302)
+                self.send_header('Location', '/auth/login')
+                self.end_headers()
+                return
+                
+        # Otherwise return 404
+        self.send_response(404)
+        self.end_headers()
+
+    def parse_POST(self):
+        """Lifted from https://stackoverflow.com/questions/4233218/"""
+        ctype, pdict = parse_header(self.headers['content-type'])
+        if ctype == 'multipart/form-data':
+            postvars = parse_multipart(self.rfile, pdict)
+        elif ctype == 'application/x-www-form-urlencoded':
+            length = int(self.headers['Content-Length'])
+            postvars = parse_qs( self.rfile.read(length), keep_blank_values=1)
+        else:
+            postvars = {}
+        return postvars
+
+socketserver.TCPServer.allow_reuse_address = True
+httpd = socketserver.TCPServer(("", PORT), AuthHandler)
+try:
+    print("serving at port", PORT)
+    httpd.serve_forever()
+finally:
+    httpd.server_close()

+ 24 - 0
build/totp/new-secret.py

@@ -0,0 +1,24 @@
+import pyotp, pyqrcode, os
+
+# Get some addition data for the QR not used for TOTP
+#issuer = input("Issuer: ")
+#email = input("E-Mail: ")
+
+issuer = '' 
+email  = '' 
+
+# Generate random secret and URL for the QR code
+TOTP_SECRET=pyotp.random_base32()
+TOTP_URL=pyotp.totp.TOTP(TOTP_SECRET).provisioning_uri(email, issuer_name=issuer)
+
+# Print Secret and QR code
+#print(TOTP_SECRET)
+#print(pyqrcode.create(TOTP_URL).terminal(quiet_zone=1))
+
+env = open('/mnt/.env', 'w')
+env.write("SECRET=" + TOTP_SECRET)
+env.close()
+
+qr = open('/mnt/secret.qr', 'w')
+qr.write(pyqrcode.create(TOTP_URL).terminal(quiet_zone=1))
+qr.close()

+ 3 - 0
build/totp/new-secret.sh

@@ -0,0 +1,3 @@
+#!/bin/bash
+
+docker run -it --rm -v `pwd`:/opt:ro python python /opt/new-secret.py

+ 16 - 0
docker-compose.yml

@@ -0,0 +1,16 @@
+services:
+  totp-auth:
+    image: toastie89/totp 
+    container_name: totp-auth
+    build: build/
+
+    environment:
+      TZ: Europe/Berlin
+      SECRET: ${SECRET}
+
+    networks:
+      - reverse-proxy_default      
+
+networks:
+  reverse-proxy_default:
+    external: true

+ 3 - 0
new-secret.sh

@@ -0,0 +1,3 @@
+#!/bin/bash
+
+docker run -it --rm -command new-secret.py -v $PWD/.env:/opt/.env

+ 16 - 0
vdi.example.com_location

@@ -0,0 +1,16 @@
+location / {
+  proxy_pass http://guacamole:8080/guacamole/;
+  proxy_redirect off;
+  proxy_buffering off;
+  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+  proxy_set_header Upgrade $http_upgrade;
+  proxy_set_header Connection $http_connection;
+  proxy_cookie_path /guacamole/ /;
+  access_log off;
+  auth_request /auth/check;
+}
+
+location /auth {
+  proxy_pass http://totp-auth:8000; # This is the TOTP Server
+  proxy_set_header X-Original-URI $request_uri;
+}