]> git.ipfire.org Git - ipfire-2.x.git/blob - config/ovpn/openvpn-authenticator
36374caf8376cd4ed9ed7b809e846bcbf959a9e3
[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 def run(self):
101 # Connect to socket
102 self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
103 self.sock.connect(self.socket_path)
104
105 log.info("OpenVPN Authenticator started")
106
107 while True:
108 line = self._read_line()
109
110 if line.startswith(">CLIENT"):
111 self._client_event(line)
112
113 log.info("OpenVPN Authenticator terminated")
114
115 def terminate(self, *args):
116 # XXX TODO
117 raise SystemExit
118
119 def _client_event(self, line):
120 # Strip away "CLIENT:"
121 client, delim, line = line.partition(":")
122
123 # Extract the event & split any arguments
124 event, delim, arguments = line.partition(",")
125 arguments = arguments.split(",")
126
127 environ = {}
128
129 if event == "CONNECT":
130 environ = self._read_env(environ)
131 self._client_connect(*arguments, environ=environ)
132 elif event == "DISCONNECT":
133 environ = self._read_env(environ)
134 self._client_disconnect(*arguments, environ=environ)
135 elif event == "REAUTH":
136 environ = self._read_env(environ)
137 self._client_reauth(*arguments, environ=environ)
138 elif event == "ESTABLISHED":
139 environ = self._read_env(environ)
140 else:
141 log.debug("Unhandled event: %s" % event)
142
143 def _read_env(self, environ):
144 # Read environment
145 while True:
146 line = self._read_line()
147
148 if not line.startswith(">CLIENT:ENV,"):
149 raise RuntimeError("Unexpected environment line: %s" % line)
150
151 # Strip >CLIENT:ENV,
152 line = line[12:]
153
154 # Done
155 if line == "END":
156 break
157
158 # Parse environment
159 key, delim, value = line.partition("=")
160 environ[key] = value
161
162 return environ
163
164 def _client_connect(self, cid, kid, environ={}):
165 log.debug("Received client connect (cid=%s, kid=%s)" % (cid, kid))
166 for key in sorted(environ):
167 log.debug(" %s : %s" % (key, environ[key]))
168
169 # Fetch common name
170 common_name = environ.get("common_name")
171
172 # Find connection details
173 conn = self._find_connection(common_name)
174 if not conn:
175 log.warning("Could not find connection '%s'" % common_name)
176 # XXX deny auth?
177
178 log.debug("Found connection:")
179 for key in conn:
180 log.debug(" %s : %s" % (key, conn[key]))
181
182 # Perform no further checks if TOTP is disabled for this client
183 if not conn.get("totp_status") == "on":
184 return self._client_auth_successful(cid, kid)
185
186 # Fetch username & password
187 username = environ.get("username")
188 password = environ.get("password")
189
190 # Client sent the special password TOTP to start challenge authentication
191 if password == "TOTP":
192 return self._client_auth_challenge(cid, kid,
193 username=common_name, password="TOTP")
194
195 elif password.startswith("CRV1:"):
196 log.debug("Received dynamic challenge response %s" % password)
197
198 # Decode the string
199 (command, flags, username, password, token) = password.split(":", 5)
200
201 # Decode username
202 username = self._b64decode(username)
203
204 # Check if username matches common name
205 if username == common_name:
206 # Check if TOTP token matches
207 if self._check_totp_token(token, conn.get("totp_secret")):
208 return self._client_auth_successful(cid, kid)
209
210 # Restart authentication
211 self._client_auth_challenge(cid, kid,
212 username=common_name, password="TOTP")
213
214 def _client_disconnect(self, cid, environ={}):
215 """
216 Handles CLIENT:DISCONNECT events
217 """
218 pass
219
220 def _client_reauth(self, cid, kid, environ={}):
221 """
222 Handles CLIENT:REAUTH events
223 """
224 # Perform no checks
225 self._client_auth_successful(cid, kid)
226
227 def _client_auth_challenge(self, cid, kid, username, password):
228 """
229 Initiates a dynamic challenge authentication with the client
230 """
231 log.debug("Sending request for dynamic challenge...")
232
233 self._send_command(
234 "client-deny %s %s \"CRV1\" \"CRV1:R,E:%s:%s:%s\"" % (
235 cid,
236 kid,
237 self._b64encode(username),
238 self._b64encode(password),
239 self._escape(CHALLENGETEXT),
240 ),
241 )
242
243 def _client_auth_successful(self, cid, kid):
244 """
245 Sends a positive authentication response
246 """
247 log.debug("Client Authentication Successful (cid=%s, kid=%s)" % (cid, kid))
248
249 self._send_command(
250 "client-auth-nt %s %s" % (cid, kid),
251 )
252
253 @staticmethod
254 def _b64encode(s):
255 return base64.b64encode(s.encode()).decode()
256
257 @staticmethod
258 def _b64decode(s):
259 return base64.b64decode(s.encode()).decode()
260
261 @staticmethod
262 def _escape(s):
263 return s.replace(" ", "\ ")
264
265 def _find_connection(self, common_name):
266 with open(OPENVPN_CONFIG, "r") as f:
267 for row in csv.reader(f, dialect="unix"):
268 # Skip empty rows or rows that are too short
269 if not row or len(row) < 5:
270 continue
271
272 # Skip disabled connections
273 if not row[1] == "on":
274 continue
275
276 # Skip any net-2-net connections
277 if not row[4] == "host":
278 continue
279
280 # Skip if common name does not match
281 if not row[3] == common_name:
282 continue
283
284 # Return match!
285 conn = {
286 "name" : row[2],
287 "common_name" : row[3],
288 }
289
290 # TOTP options
291 try:
292 conn |= {
293 "totp_protocol" : row[43],
294 "totp_status" : row[44],
295 "totp_secret" : row[45],
296 }
297 except IndexError:
298 pass
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()