2 Commits f3061de49d ... aaec6759c8

Author SHA1 Message Date
  Dominik aaec6759c8 initial 1 year ago
  Dominik 676487ba92 initial 1 year ago
10 changed files with 409 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 165 0
      CODE-EXPLAINED.md
  3. 4 0
      README.md
  4. 18 0
      build/Dockerfile
  5. 158 0
      build/totp/main.py
  6. 24 0
      build/totp/new-secret.py
  7. 3 0
      build/totp/new-secret.sh
  8. 16 0
      docker-compose.yml
  9. 3 0
      new-secret.sh
  10. 16 0
      vdi.example.com_location

+ 2 - 0
.gitignore

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

+ 165 - 0
CODE-EXPLAINED.md

@@ -0,0 +1,165 @@
+Some explanation on components used for the authentication proxy...
+
+
+### Show simple page
+Creates an object of the class [HTTPServer](https://docs.python.org/3/library/http.server.html) which takes as parameter:
+  - a tuple of (host, port) to bind. Empty host binds to all interfaces.
+  - an object of the class BaseHTTPRequestHandlerto to answer http requests, e.g. GET, POST, ...
+
+```
+from http.server import BaseHTTPRequestHandler, HTTPServer
+httpd = HTTPServer(('', 8000), BaseHTTPRequestHandler)
+httpd.serve_forever()
+```
+
+To actually answer http requests, a new class has to be defined
+which inherits BaseHTTPRequestHandler an overwrites the functions
+to handle different http request types such a GET, POST, ...
+```
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+HOST='localhost'
+PORT=8000
+
+s = """<html>
+<head><title>hello, world</title></head>
+<body>hello, world</body>
+</html>"""
+
+class OtpRequestHandler(BaseHTTPRequestHandler):
+    def do_GET(self):
+        self.send_response(200) #OK
+        self.send_header("Content-type", "text/html")
+        self.end_headers()
+        self.wfile.write(bytes(s, "utf-8"))
+
+httpd = HTTPServer((HOST, PORT), OtpRequestHandler)
+httpd.serve_forever()
+```
+
+To test: `curl http://localhost:8000/`
+
+
+
+### Print POST data
+- To read data received via post we need to obtain the length
+  of the post data from the http-header first.
+- Form data is sent with the content-type application/x-www-form-urlencoded
+
+```
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+class OtpRequestHandler(BaseHTTPRequestHandler):
+    def do_POST(self):
+        length = int(self.headers['Content-Length'])
+        print(self.headers['content-type'])
+        print(self.rfile.read(length))
+        
+httpd = HTTPServer(('localhost', 8000), OtpRequestHandler)
+httpd.serve_forever()
+```
+
+To Test: `curl -d "hello, world" -X POST http://localhost:8000/`
+
+
+
+### Parse HTML form data
+- parse_qs parses the form data into a dictionary.
+- Form values are stored as list in the dicionary,
+  which has to be converted to a string
+
+```
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs
+
+class OtpRequestHandler(BaseHTTPRequestHandler):
+    def do_POST(self):
+        length = int(self.headers['Content-Length'])
+        parms = parse_qs(self.rfile.read(length).decode("utf-8"))
+        print("User: " + ''.join(parms["user"]) )
+        print("OTP: "  + ''.join(parms["otp"]) )
+
+httpd = HTTPServer(('localhost', 8000), OtpRequestHandler)
+httpd.serve_forever()
+```
+
+To Test: `curl -d "user=jdoe&otp=204177" -X POST http://localhost:8000/`
+
+
+
+### Set Cookie
+```
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from http.cookies import SimpleCookie
+import secrets
+
+cookie = SimpleCookie()
+cookie['token'] = secrets.token_urlsafe(16)
+#cookie["token"]["domain"] = "s-up.net"
+cookie["token"]["path"] = "/"
+#cookie["token"]["secure"] = True
+cookie["token"]["httponly"] = True
+cookies.Morsel._reserved.setdefault('samesite', 'SameSite')
+cookie["token"]["samesite"] = "strict"
+cookie["token"]["expires"] = 60 * 60 * 6 # 6h
+
+class OtpRequestHandler(BaseHTTPRequestHandler):
+    def do_GET(self):
+        self.send_response(200) #OK
+        self.send_header("Content-type", "text/html")
+        self.send_header('Set-Cookie', cookie.output(header=''))
+        self.end_headers()
+        self.wfile.write(bytes("Hello", "utf-8"))
+
+httpd = HTTPServer(('localhost', 8000), OtpRequestHandler)
+httpd.serve_forever()
+```
+
+### Get Cookie
+```
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from http.cookies import SimpleCookie
+import secrets
+
+class OtpRequestHandler(BaseHTTPRequestHandler):
+    def do_GET(self):
+        self.send_response(200) #OK
+        self.send_header("Content-type", "text/html")
+        self.end_headers()
+        cookies = SimpleCookie(self.headers.get('Cookie'))
+        token = cookies['token'].value
+        self.wfile.write(bytes(token, "utf-8"))
+        print(token)
+
+httpd = HTTPServer(('localhost', 8000), OtpRequestHandler)
+httpd.serve_forever()
+```
+
+
+
+### TOTP
+```
+import pyotp
+import time
+
+# Get a random secret
+secret=pyotp.random_base32()
+print(secret)
+
+# Calculate OTP
+otp=pyotp.TOTP(secret).now()
+print(otp)
+
+# Verify OTP, should be true
+pyotp.TOTP(secret).verify(otp)
+
+# Verify OTP again after 30s, should be false
+time.sleep(30)
+pyotp.TOTP(secret).verify(otp)
+```
+
+
+## References
+
+- [Online TOTP Generator](https://totp.danhersam.com/)
+- [simpleotp on Github](https://github.com/newhouseb/simpleotp)

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