├── main.py ├── readme.md └── requirements.txt /main.py: -------------------------------------------------------------------------------- 1 | import random 2 | import socketserver 3 | import http.server 4 | import http.cookies 5 | import json 6 | import base64 7 | import sys 8 | import os 9 | import time 10 | 11 | from fido2.client import ClientData 12 | from fido2.server import U2FFido2Server, RelyingParty 13 | from fido2.ctap2 import AttestationObject, AttestedCredentialData, AuthenticatorData 14 | from fido2 import cbor 15 | 16 | TOKEN_LIFETIME = 60 * 60 * 24 17 | PORT = 8000 18 | FORM = """ 19 |
20 | 69 | 70 | 71 | """ 72 | 73 | class TokenManager(object): 74 | """Who needs a database when you can just store everything in memory?""" 75 | 76 | def __init__(self): 77 | self.tokens = {} 78 | self.random = random.SystemRandom() 79 | 80 | def generate(self): 81 | t = '%064x' % self.random.getrandbits(8*32) 82 | self.tokens[t] = time.time() 83 | return t 84 | 85 | def is_valid(self, t): 86 | try: 87 | return time.time() - self.tokens.get(t, 0) < TOKEN_LIFETIME 88 | except Exception: 89 | return False 90 | 91 | def invalidate(self, t): 92 | if t in self.tokens: 93 | del self.tokens[t] 94 | 95 | CHALLENGE = {} 96 | TOKEN_MANAGER = TokenManager() 97 | 98 | class AuthHandler(http.server.BaseHTTPRequestHandler): 99 | def do_GET(self): 100 | if self.path == '/auth/check': 101 | cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) 102 | if 'token' in cookie and TOKEN_MANAGER.is_valid(cookie['token'].value): 103 | self.send_response(200) 104 | self.end_headers() 105 | return 106 | 107 | self.send_response(401) 108 | self.end_headers() 109 | return 110 | 111 | if self.path == "/auth/login": 112 | self.send_response(200) 113 | self.send_header('Content-type', 'text/html') 114 | self.end_headers() 115 | self.wfile.write(bytes(FORM, 'UTF-8')) 116 | return 117 | 118 | if self.path == '/auth/logout': 119 | cookie = http.cookies.SimpleCookie(self.headers.get('Cookie')) 120 | if 'token' in cookie: 121 | TOKEN_MANAGER.invalidate(cookie['token'].value) 122 | 123 | # This just replaces the token with garbage 124 | self.send_response(302) 125 | cookie = http.cookies.SimpleCookie() 126 | cookie["token"] = '***' 127 | cookie["token"]["path"] = '/' 128 | cookie["token"]["secure"] = True 129 | self.send_header('Set-Cookie', cookie.output(header='')) 130 | self.send_header('Location', '/') 131 | self.end_headers() 132 | 133 | self.send_response(404) 134 | self.end_headers() 135 | 136 | def do_POST(self): 137 | origin = self.headers.get('Origin') 138 | host = origin[len('https://'):] 139 | 140 | rp = RelyingParty(host, 'NGINX Auth Server') 141 | server = U2FFido2Server(origin, rp) 142 | 143 | if self.path == "/auth/get_challenge_for_new_key": 144 | registration_data, state = server.register_begin({ 'id': b'default', 'name': "Default user", 'displayName': "Default user" }) 145 | registration_data["publicKey"]["challenge"] = str(base64.b64encode(registration_data["publicKey"]["challenge"]), 'utf-8') 146 | registration_data["publicKey"]["user"]["id"] = str(base64.b64encode(registration_data["publicKey"]["user"]["id"]), 'utf-8') 147 | 148 | self.send_response(200) 149 | self.send_header('Content-type', 'application/json') 150 | self.end_headers() 151 | # Save this challenge to a file so you can kill the host to add the lient via CLI 152 | with open('.lastchallenge', 'w') as f: 153 | f.write(json.dumps(state)) 154 | self.wfile.write(bytes(json.dumps(registration_data), 'UTF-8')) 155 | return 156 | 157 | if not os.path.exists('.credentials'): 158 | self.send_response(401) 159 | self.send_header('Content-type', 'application/json') 160 | self.end_headers() 161 | self.wfile.write(bytes(json.dumps({'error': 'not_configured'}), 'UTF-8')) 162 | return 163 | 164 | creds = [] 165 | with open('.credentials', 'rb') as f: 166 | cred, _ = AttestedCredentialData.unpack_from(f.read()) 167 | creds.append(cred) 168 | 169 | if self.path == "/auth/get_challenge_for_existing_key": 170 | auth_data, state = server.authenticate_begin(creds) 171 | auth_data["publicKey"]["challenge"] = str(base64.b64encode(auth_data["publicKey"]["challenge"]), 'utf-8') 172 | auth_data["publicKey"]["allowCredentials"][0]["id"] = str(base64.b64encode(auth_data["publicKey"]["allowCredentials"][0]["id"]), 'utf-8') 173 | 174 | CHALLENGE.update(state) 175 | 176 | self.send_response(200) 177 | self.send_header('Content-type', 'application/json') 178 | self.end_headers() 179 | self.wfile.write(bytes(json.dumps(auth_data), 'UTF-8')) 180 | 181 | if self.path == "/auth/complete_challenge_for_existing_key": 182 | data = json.loads(self.rfile.read(int(self.headers.get('Content-Length')))) 183 | 184 | credential_id = base64.b64decode(data['id']) 185 | client_data = ClientData(base64.b64decode(data['clientDataJSON'])) 186 | auth_data = AuthenticatorData(base64.b64decode(data['authenticatorData'])) 187 | signature = base64.b64decode(data['signature']) 188 | 189 | with open('.lastchallenge') as f: 190 | server.authenticate_complete( 191 | CHALLENGE, 192 | creds, 193 | credential_id, 194 | client_data, 195 | auth_data, 196 | signature 197 | ) 198 | 199 | cookie = http.cookies.SimpleCookie() 200 | cookie["token"] = TOKEN_MANAGER.generate() 201 | cookie["token"]["path"] = "/" 202 | cookie["token"]["secure"] = True 203 | 204 | self.send_response(200) 205 | self.send_header('Set-Cookie', cookie.output(header='')) 206 | self.end_headers() 207 | self.wfile.write(bytes(json.dumps({'status': 'ok'}), 'UTF-8')) 208 | 209 | if len(sys.argv) > 1 and sys.argv[1] == "save-client": 210 | host = sys.argv[2] 211 | client_data = ClientData(base64.b64decode(sys.argv[3])) 212 | attestation_object = AttestationObject(base64.b64decode(sys.argv[4])) 213 | 214 | rp = RelyingParty(host, 'NGINX Auth Server') 215 | server = U2FFido2Server('https://' + host, rp) 216 | 217 | with open('.lastchallenge') as f: 218 | auth_data = server.register_complete(json.loads(f.read()), client_data, attestation_object) 219 | with open('.credentials', 'wb') as f: 220 | f.write(auth_data.credential_data) 221 | 222 | print("Credentials saved successfully") 223 | 224 | else: 225 | socketserver.TCPServer.allow_reuse_address = True 226 | httpd = socketserver.TCPServer(("", PORT), AuthHandler) 227 | try: 228 | print("serving at port", PORT) 229 | httpd.serve_forever() 230 | finally: 231 | httpd.server_close() 232 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # NGINX + WebAuthn for your small scale web applications 2 | 3 | ## What is this for? 4 | 5 | If you run some small services on a public-facing server that you would like to protect (i.e. Jupyter of VS code-server) and have a Yubikey or similar, you can use this repository to add secure, public-key authentication to them **without modifying the original service itself**. 6 | 7 | ## How? 8 | 9 | Set up NGINX to proxy your service, note that you will also need SSL because WebAuthn only works over HTTPS. I highly recommend using Let's Encrypt + `certbot` so set up SSL: 10 | 11 | ``` 12 | server { 13 | server_name myserver.bennewhouse.com; # managed by Certbot 14 | 15 | # Redirect everything that begins with /auth to the authorization server 16 | location /auth { 17 | proxy_pass http://127.0.0.1:8000; 18 | } 19 | 20 | # If the authorization server returns 401 Unauthorized, redirect to /atuh/login 21 | error_page 401 = @error401; 22 | location @error401 { 23 | return 302 /auth/login; 24 | } 25 | 26 | root /var/www/html; 27 | index index.html; 28 | location / { 29 | auth_request /auth/check; # Ping /auth/check for every request, and if it returns 200 OK grant access 30 | 31 | # Here is where you would put other proxy_pass info to forward to Jupyter, etc. In this example I'm just serving raw HTML 32 | } 33 | 34 | listen [::]:443 ssl ; # managed by Certbot 35 | listen 443 ssl; # managed by Certbot 36 | ssl_certificate /etc/letsencrypt/live/myserver.bennewhouse.com/fullchain.pem; # managed by Certbot 37 | ssl_certificate_key /etc/letsencrypt/live/myserver.bennewhouse.com/privkey.pem; # managed by Certbot 38 | include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot 39 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 40 | } 41 | ``` 42 | 43 | Reload NGINX with the aforementioned configuration. Next install the required depenencies (only one at the moment) and run main.py in a long-running fashion (either in `tmux`, `screen` or if you're fancy a systemd daemon) 44 | 45 | ``` 46 | pip3 install -r requirements.txt 47 | python3 main.py 48 | ``` 49 | 50 | Browse to your site on a page that supports WebAuthn (most things other than Safari). Insert your security key when requested, and the page will tell you to run a command that looks like: 51 | 52 | ``` 53 | python3 main.py save-client myserver.bennewhouse.com *big long base64 string* *big long base64 string* 54 | ``` 55 | 56 | Run that from the same place you've checked out this code. You only need to do this once to authorize your key. 57 | 58 | That's it! Navigating back to your website will now authenticate you using the key you just saved. 59 | 60 | ## Limitations 61 | 62 | - At the moment, we only store one set of credentials. It'd be nice to store multiple credentials, especially across different domains. 63 | - This uses the built-in python3 server, which isn't designed for high-volume. You'd want to port this to a uwsgi setup if you wanted to productionize it. 64 | 65 | ## FAQ 66 | 67 | *Why do I need to run the `save-client` command?* 68 | 69 | This seemed easier than setting up a potentially insecure password so that you could authorize your key. Instead it asserts that you have shell access by requiring that you run a command. 70 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fido2==0.6.0 2 | --------------------------------------------------------------------------------