]> git.ipfire.org Git - people/pmueller/ipfire-2.x.git/blob - config/ovpn/openvpn-authenticator
65844012b24dd1067e0bb9f9afe37c7f5efd2cc3
[people/pmueller/ipfire-2.x.git] / config / ovpn / openvpn-authenticator
1 #!/usr/bin/python3
2 ###############################################################################
3 # #
4 # IPFire.org - A linux based firewall #
5 # Copyright (C) 2022 Michael Tremer #
6 # #
7 # This program is free software: you can redistribute it and/or modify #
8 # it under the terms of the GNU General Public License as published by #
9 # the Free Software Foundation, either version 3 of the License, or #
10 # (at your option) any later version. #
11 # #
12 # This program is distributed in the hope that it will be useful, #
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
15 # GNU General Public License for more details. #
16 # #
17 # You should have received a copy of the GNU General Public License #
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. #
19 # #
20 ###############################################################################
21
22 import argparse
23 import base64
24 import csv
25 import daemon
26 import logging
27 import logging.handlers
28 import signal
29 import socket
30 import subprocess
31 import sys
32
33 OPENVPN_CONFIG = "/var/ipfire/ovpn/ovpnconfig"
34
35 CHALLENGETEXT = "One Time Token: "
36
37 log = logging.getLogger()
38 log.setLevel(logging.DEBUG)
39
40 def setup_logging(daemon=True, loglevel=logging.INFO):
41 log.setLevel(loglevel)
42
43 # Log to syslog by default
44 handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
45 log.addHandler(handler)
46
47 # Format everything
48 formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
49 handler.setFormatter(formatter)
50
51 handler.setLevel(loglevel)
52
53 # If we are running in foreground, we should write everything to the console, too
54 if not daemon:
55 handler = logging.StreamHandler()
56 log.addHandler(handler)
57
58 handler.setLevel(loglevel)
59
60 return log
61
62 class OpenVPNAuthenticator(object):
63 def __init__(self, socket_path):
64 self.socket_path = socket_path
65
66 def _read_line(self):
67 buf = []
68
69 while True:
70 char = self.sock.recv(1)
71 buf.append(char)
72
73 # Reached end of line
74 if char == b"\n":
75 break
76
77 line = b"".join(buf).decode()
78 line = line.rstrip()
79
80 log.debug("< %s" % line)
81
82 return line
83
84 def _write_line(self, line):
85 log.debug("> %s" % line)
86
87 if not line.endswith("\n"):
88 line = "%s\n" % line
89
90 # Convert into bytes
91 buf = line.encode()
92
93 # Send to socket
94 self.sock.send(buf)
95
96 def _send_command(self, command):
97 # Send the command
98 self._write_line(command)
99
100 return # XXX Code below doesn't work
101
102 # Read response
103 response = self._read_line()
104
105 # Handle response
106 if not response.startswith("SUCCESS:"):
107 log.error("Command '%s' returned an error:" % command)
108 log.error(" %s" % response)
109
110 return response
111
112 def run(self):
113 # Connect to socket
114 self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
115 self.sock.connect(self.socket_path)
116
117 log.info("OpenVPN Authenticator started")
118
119 while True:
120 line = self._read_line()
121
122 if line.startswith(">CLIENT"):
123 self._client_event(line)
124
125 log.info("OpenVPN Authenticator terminated")
126
127 def terminate(self, *args):
128 # XXX TODO
129 raise SystemExit
130
131 def _client_event(self, line):
132 # Strip away "CLIENT:"
133 client, delim, line = line.partition(":")
134
135 # Extract the event & split any arguments
136 event, delim, arguments = line.partition(",")
137 arguments = arguments.split(",")
138
139 environ = {}
140
141 if event == "CONNECT":
142 environ = self._read_env(environ)
143 self._client_connect(*arguments, environ=environ)
144 elif event == "DISCONNECT":
145 environ = self._read_env(environ)
146 self._client_disconnect(*arguments, environ=environ)
147 elif event == "REAUTH":
148 environ = self._read_env(environ)
149 self._client_reauth(*arguments, environ=environ)
150 elif event == "ESTABLISHED":
151 environ = self._read_env(environ)
152 else:
153 log.debug("Unhandled event: %s" % event)
154
155 def _read_env(self, environ):
156 # Read environment
157 while True:
158 line = self._read_line()
159
160 if not line.startswith(">CLIENT:ENV,"):
161 raise RuntimeError("Unexpected environment line: %s" % line)
162
163 # Strip >CLIENT:ENV,
164 line = line[12:]
165
166 # Done
167 if line == "END":
168 break
169
170 # Parse environment
171 key, delim, value = line.partition("=")
172 environ[key] = value
173
174 return environ
175
176 def _client_connect(self, cid, kid, environ={}):
177 log.debug("Received client connect (cid=%s, kid=%s)" % (cid, kid))
178 for key in sorted(environ):
179 log.debug(" %s : %s" % (key, environ[key]))
180
181 # Fetch common name
182 common_name = environ.get("common_name")
183
184 # Find connection details
185 conn = self._find_connection(common_name)
186 if not conn:
187 log.warning("Could not find connection '%s'" % common_name)
188 # XXX deny auth?
189
190 log.debug("Found connection:")
191 for key in conn:
192 log.debug(" %s : %s" % (key, conn[key]))
193
194 # Perform no further checks if TOTP is disabled for this client
195 if not conn.get("totp_status") == "on":
196 return self._client_auth_successful(cid, kid)
197
198 # Fetch username & password
199 username = environ.get("username")
200 password = environ.get("password")
201
202 # Client sent the special password TOTP to start challenge authentication
203 if password == "TOTP":
204 return self._client_auth_challenge(cid, kid,
205 username=common_name, password="TOTP")
206
207 elif password.startswith("CRV1:"):
208 log.debug("Received dynamic challenge response %s" % password)
209
210 # Decode the string
211 (command, flags, username, password, token) = password.split(":", 5)
212
213 # Decode username
214 username = self._b64decode(username)
215
216 # Check if username matches common name
217 if username == common_name:
218 # Check if TOTP token matches
219 if self._check_totp_token(token, conn.get("totp_secret")):
220 return self._client_auth_successful(cid, kid)
221
222 # Restart authentication
223 self._client_auth_challenge(cid, kid,
224 username=common_name, password="TOTP")
225
226 def _client_disconnect(self, cid, environ={}):
227 """
228 Handles CLIENT:DISCONNECT events
229 """
230 pass
231
232 def _client_reauth(self, cid, kid, environ={}):
233 """
234 Handles CLIENT:REAUTH events
235 """
236 # Perform no checks
237 self._client_auth_successful(cid, kid)
238
239 def _client_auth_challenge(self, cid, kid, username, password):
240 """
241 Initiates a dynamic challenge authentication with the client
242 """
243 log.debug("Sending request for dynamic challenge...")
244
245 self._send_command(
246 "client-deny %s %s \"CRV1\" \"CRV1:R,E:%s:%s:%s\"" % (
247 cid,
248 kid,
249 self._b64encode(username),
250 self._b64encode(password),
251 self._escape(CHALLENGETEXT),
252 ),
253 )
254
255 def _client_auth_successful(self, cid, kid):
256 """
257 Sends a positive authentication response
258 """
259 log.debug("Client Authentication Successful (cid=%s, kid=%s)" % (cid, kid))
260
261 self._send_command(
262 "client-auth-nt %s %s" % (cid, kid),
263 )
264
265 @staticmethod
266 def _b64encode(s):
267 return base64.b64encode(s.encode()).decode()
268
269 @staticmethod
270 def _b64decode(s):
271 return base64.b64decode(s.encode()).decode()
272
273 @staticmethod
274 def _escape(s):
275 return s.replace(" ", "\ ")
276
277 def _find_connection(self, common_name):
278 with open(OPENVPN_CONFIG, "r") as f:
279 for row in csv.reader(f, dialect="unix"):
280 # Skip empty rows or rows that are too short
281 if not row or len(row) < 5:
282 continue
283
284 # Skip disabled connections
285 if not row[1] == "on":
286 continue
287
288 # Skip any net-2-net connections
289 if not row[4] == "host":
290 continue
291
292 # Skip if common name does not match
293 if not row[3] == common_name:
294 continue
295
296 # Return match!
297 conn = {
298 "name" : row[2],
299 "common_name" : row[3],
300 }
301
302 # TOTP options
303 try:
304 conn |= {
305 "totp_protocol" : row[43],
306 "totp_status" : row[44],
307 "totp_secret" : row[45],
308 }
309 except IndexError:
310 pass
311
312 return conn
313
314
315 def _check_totp_token(self, token, secret):
316 p = subprocess.run(
317 ["oathtool", "--totp", "-w", "3", "%s" % secret],
318 capture_output=True,
319 )
320
321 # Catch any errors if we could not run the command
322 if p.returncode:
323 log.error("Could not run oathtool: %s" % p.stderr)
324
325 return False
326
327 # Reading returned tokens looking for a match
328 for line in p.stdout.split(b"\n"):
329 # Skip empty/last line(s)
330 if not line:
331 continue
332
333 # Decode bytes into string
334 line = line.decode()
335
336 # Return True if a token matches
337 if line == token:
338 return True
339
340 # No match
341 return False
342
343
344 if __name__ == "__main__":
345 parser = argparse.ArgumentParser(description="OpenVPN Authenticator")
346
347 # Daemon Stuff
348 parser.add_argument("--daemon", "-d", action="store_true",
349 help="Launch as daemon in background")
350 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
351
352 # Paths
353 parser.add_argument("--socket", default="/var/run/openvpn.sock",
354 metavar="PATH", help="Path to OpenVPN Management Socket")
355
356 # Parse command line arguments
357 args = parser.parse_args()
358
359 # Setup logging
360 loglevel = logging.WARN
361
362 if args.verbose:
363 if args.verbose == 1:
364 loglevel = logging.INFO
365 elif args.verbose >= 2:
366 loglevel = logging.DEBUG
367
368 # Create an authenticator
369 authenticator = OpenVPNAuthenticator(args.socket)
370
371 with daemon.DaemonContext(
372 detach_process=args.daemon,
373 stderr=None if args.daemon else sys.stderr,
374 signal_map = {
375 signal.SIGINT : authenticator.terminate,
376 signal.SIGTERM : authenticator.terminate,
377 },
378 ) as daemon:
379 setup_logging(daemon=args.daemon, loglevel=loglevel)
380
381 authenticator.run()