]> git.ipfire.org Git - people/teissler/ipfire-2.x.git/blob - config/ovpn/openvpn-authenticator
25d96ca348866b0cd0233e290100936257626b67
[people/teissler/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 # Read environment
142 while True:
143 line = self._read_line()
144
145 if not line.startswith(">CLIENT:ENV,"):
146 raise RuntimeError("Unexpected environment line: %s" % line)
147
148 # Strip >CLIENT:ENV,
149 line = line[12:]
150
151 # Done
152 if line == "END":
153 break
154
155 # Parse environment
156 key, delim, value = line.partition("=")
157 environ[key] = value
158
159 if event == "CONNECT":
160 self._client_connect(*arguments, environ=environ)
161 elif event == "DISCONNECT":
162 self._client_disconnect(*arguments, environ=environ)
163 elif event == "REAUTH":
164 self._client_reauth(*arguments, environ=environ)
165 else:
166 log.debug("Unhandled event: %s" % event)
167
168 def _client_connect(self, cid, kid, environ={}):
169 log.debug("Received client connect (cid=%s, kid=%s)" % (cid, kid))
170 for key in sorted(environ):
171 log.debug(" %s : %s" % (key, environ[key]))
172
173 # Fetch common name
174 common_name = environ.get("common_name")
175
176 # Find connection details
177 conn = self._find_connection(common_name)
178 if not conn:
179 log.warning("Could not find connection '%s'" % common_name)
180 # XXX deny auth?
181
182 log.debug("Found connection:")
183 for key in conn:
184 log.debug(" %s : %s" % (key, conn[key]))
185
186 # Perform no further checks if TOTP is disabled for this client
187 if not conn.get("totp_status") == "on":
188 return self._client_auth_successful(cid, kid)
189
190 # Fetch username & password
191 username = environ.get("username")
192 password = environ.get("password")
193
194 # Client sent the special password TOTP to start challenge authentication
195 if password == "TOTP":
196 return self._client_auth_challenge(cid, kid,
197 username=common_name, password="TOTP")
198
199 elif password.startswith("CRV1:"):
200 log.debug("Received dynamic challenge response %s" % password)
201
202 # Decode the string
203 (command, flags, username, password, token) = password.split(":", 5)
204
205 # Decode username
206 username = self._b64decode(username)
207
208 # Check if username matches common name
209 if username == common_name:
210 # Check if TOTP token matches
211 if self._check_totp_token(token, conn.get("totp_secret")):
212 return self._client_auth_successful(cid, kid)
213
214 # Restart authentication
215 self._client_auth_challenge(cid, kid,
216 username=common_name, password="TOTP")
217
218 def _client_disconnect(self, cid, environ={}):
219 """
220 Handles CLIENT:DISCONNECT events
221 """
222 pass
223
224 def _client_reauth(self, cid, kid, environ={}):
225 """
226 Handles CLIENT:REAUTH events
227 """
228 # Perform no checks
229 self._client_auth_successful(cid, kid)
230
231 def _client_auth_challenge(self, cid, kid, username, password):
232 """
233 Initiates a dynamic challenge authentication with the client
234 """
235 log.debug("Sending request for dynamic challenge...")
236
237 self._send_command(
238 "client-deny %s %s \"CRV1\" \"CRV1:R,E:%s:%s:%s\"" % (
239 cid,
240 kid,
241 self._b64encode(username),
242 self._b64encode(password),
243 self._escape(CHALLENGETEXT),
244 ),
245 )
246
247 def _client_auth_successful(self, cid, kid):
248 """
249 Sends a positive authentication response
250 """
251 log.debug("Client Authentication Successful (cid=%s, kid=%s)" % (cid, kid))
252
253 self._send_command(
254 "client-auth-nt %s %s" % (cid, kid),
255 )
256
257 @staticmethod
258 def _b64encode(s):
259 return base64.b64encode(s.encode()).decode()
260
261 @staticmethod
262 def _b64decode(s):
263 return base64.b64decode(s.encode()).decode()
264
265 @staticmethod
266 def _escape(s):
267 return s.replace(" ", "\ ")
268
269 def _find_connection(self, common_name):
270 with open(OPENVPN_CONFIG, "r") as f:
271 for row in csv.reader(f, dialect="unix"):
272 # Skip empty rows
273 if not row:
274 continue
275
276 # Skip disabled connections
277 if not row[1] == "on":
278 continue
279
280 # Skip any net-2-net connections
281 if not row[4] == "host":
282 continue
283
284 # Skip if common name does not match
285 if not row[3] == common_name:
286 continue
287
288 # Return match!
289 conn = {}
290 if len(row) < 45:
291 # General connection data
292 conn['name'] = row[2]
293 conn['common_name'] = row[3]
294 elif len(row) >= 45:
295 # TOTP options
296 conn['totp_protocol'] = row[43]
297 conn['totp_status'] = row[44]
298 conn['totp_secret'] = row[45]
299
300 return conn
301
302
303 def _check_totp_token(self, token, secret):
304 p = subprocess.run(
305 ["oathtool", "--totp", "-w", "3", "%s" % secret],
306 capture_output=True,
307 )
308
309 # Catch any errors if we could not run the command
310 if p.returncode:
311 log.error("Could not run oathtool: %s" % p.stderr)
312
313 return False
314
315 # Reading returned tokens looking for a match
316 for line in p.stdout.split(b"\n"):
317 # Skip empty/last line(s)
318 if not line:
319 continue
320
321 # Decode bytes into string
322 line = line.decode()
323
324 # Return True if a token matches
325 if line == token:
326 return True
327
328 # No match
329 return False
330
331
332 if __name__ == "__main__":
333 parser = argparse.ArgumentParser(description="OpenVPN Authenticator")
334
335 # Daemon Stuff
336 parser.add_argument("--daemon", "-d", action="store_true",
337 help="Launch as daemon in background")
338 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
339
340 # Paths
341 parser.add_argument("--socket", default="/var/run/openvpn.sock",
342 metavar="PATH", help="Path to OpenVPN Management Socket")
343
344 # Parse command line arguments
345 args = parser.parse_args()
346
347 # Setup logging
348 loglevel = logging.WARN
349
350 if args.verbose:
351 if args.verbose == 1:
352 loglevel = logging.INFO
353 elif args.verbose >= 2:
354 loglevel = logging.DEBUG
355
356 # Create an authenticator
357 authenticator = OpenVPNAuthenticator(args.socket)
358
359 with daemon.DaemonContext(
360 detach_process=args.daemon,
361 stderr=None if args.daemon else sys.stderr,
362 signal_map = {
363 signal.SIGINT : authenticator.terminate,
364 signal.SIGTERM : authenticator.terminate,
365 },
366 ) as daemon:
367 setup_logging(daemon=args.daemon, loglevel=loglevel)
368
369 authenticator.run()