]>
git.ipfire.org Git - ipfire-2.x.git/blob - config/ovpn/openvpn-authenticator
2 ###############################################################################
4 # IPFire.org - A linux based firewall #
5 # Copyright (C) 2022 Michael Tremer #
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. #
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. #
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/>. #
20 ###############################################################################
27 import logging
.handlers
33 OPENVPN_CONFIG
= "/var/ipfire/ovpn/ovpnconfig"
35 CHALLENGETEXT
= "One Time Token: "
37 log
= logging
.getLogger()
38 log
.setLevel(logging
.DEBUG
)
40 def setup_logging(daemon
=True, loglevel
=logging
.INFO
):
41 log
.setLevel(loglevel
)
43 # Log to syslog by default
44 handler
= logging
.handlers
.SysLogHandler(address
="/dev/log", facility
="daemon")
45 log
.addHandler(handler
)
48 formatter
= logging
.Formatter("%(name)s[%(process)d]: %(message)s")
49 handler
.setFormatter(formatter
)
51 handler
.setLevel(loglevel
)
53 # If we are running in foreground, we should write everything to the console, too
55 handler
= logging
.StreamHandler()
56 log
.addHandler(handler
)
58 handler
.setLevel(loglevel
)
62 class OpenVPNAuthenticator(object):
63 def __init__(self
, socket_path
):
64 self
.socket_path
= socket_path
70 char
= self
.sock
.recv(1)
72 # Break if we could not read from the socket
74 raise EOFError("Could not read from socket")
83 line
= b
"".join(buf
).decode()
86 log
.debug("< %s" % line
)
90 def _write_line(self
, line
):
91 log
.debug("> %s" % line
)
93 if not line
.endswith("\n"):
102 def _send_command(self
, command
):
104 self
._write
_line
(command
)
108 self
.sock
= socket
.socket(socket
.AF_UNIX
, socket
.SOCK_STREAM
)
109 self
.sock
.connect(self
.socket_path
)
111 log
.info("OpenVPN Authenticator started")
115 line
= self
._read
_line
()
117 if line
.startswith(">CLIENT"):
118 self
._client
_event
(line
)
120 # Terminate the daemon when it loses its connection to the OpenVPN daemon
121 except (ConnectionResetError
, EOFError) as e
:
122 log
.error("Connection to OpenVPN has been lost: %s" % e
)
124 log
.info("OpenVPN Authenticator terminated")
126 def terminate(self
, *args
):
130 def _client_event(self
, line
):
131 # Strip away "CLIENT:"
132 client
, delim
, line
= line
.partition(":")
134 # Extract the event & split any arguments
135 event
, delim
, arguments
= line
.partition(",")
136 arguments
= arguments
.split(",")
140 if event
== "CONNECT":
141 environ
= self
._read
_env
(environ
)
142 self
._client
_connect
(*arguments
, environ
=environ
)
143 elif event
== "DISCONNECT":
144 environ
= self
._read
_env
(environ
)
145 self
._client
_disconnect
(*arguments
, environ
=environ
)
146 elif event
== "REAUTH":
147 environ
= self
._read
_env
(environ
)
148 self
._client
_reauth
(*arguments
, environ
=environ
)
149 elif event
== "ESTABLISHED":
150 environ
= self
._read
_env
(environ
)
152 log
.debug("Unhandled event: %s" % event
)
154 def _read_env(self
, environ
):
157 line
= self
._read
_line
()
159 if not line
.startswith(">CLIENT:ENV,"):
160 raise RuntimeError("Unexpected environment line: %s" % line
)
170 key
, delim
, value
= line
.partition("=")
175 def _client_connect(self
, cid
, kid
, environ
={}):
176 log
.debug("Received client connect (cid=%s, kid=%s)" % (cid
, kid
))
177 for key
in sorted(environ
):
178 log
.debug(" %s : %s" % (key
, environ
[key
]))
181 common_name
= environ
.get("common_name")
183 # Find connection details
184 conn
= self
._find
_connection
(common_name
)
186 log
.warning("Could not find connection '%s'" % common_name
)
189 log
.debug("Found connection:")
191 log
.debug(" %s : %s" % (key
, conn
[key
]))
193 # Perform no further checks if TOTP is disabled for this client
194 if not conn
.get("totp_status") == "on":
195 return self
._client
_auth
_successful
(cid
, kid
)
197 # Fetch username & password
198 username
= environ
.get("username")
199 password
= environ
.get("password")
201 # Client sent the special password TOTP to start challenge authentication
202 if password
== "TOTP":
203 return self
._client
_auth
_challenge
(cid
, kid
,
204 username
=common_name
, password
="TOTP")
206 elif password
.startswith("CRV1:"):
207 log
.debug("Received dynamic challenge response %s" % password
)
210 (command
, flags
, username
, password
, token
) = password
.split(":", 5)
213 username
= self
._b
64decode
(username
)
215 # Check if username matches common name
216 if username
== common_name
:
217 # Check if TOTP token matches
218 if self
._check
_totp
_token
(token
, conn
.get("totp_secret")):
219 return self
._client
_auth
_successful
(cid
, kid
)
221 # Restart authentication
222 self
._client
_auth
_challenge
(cid
, kid
,
223 username
=common_name
, password
="TOTP")
225 def _client_disconnect(self
, cid
, environ
={}):
227 Handles CLIENT:DISCONNECT events
231 def _client_reauth(self
, cid
, kid
, environ
={}):
233 Handles CLIENT:REAUTH events
236 self
._client
_auth
_successful
(cid
, kid
)
238 def _client_auth_challenge(self
, cid
, kid
, username
, password
):
240 Initiates a dynamic challenge authentication with the client
242 log
.debug("Sending request for dynamic challenge...")
245 "client-deny %s %s \"CRV1\" \"CRV1:R,E:%s:%s:%s\"" % (
248 self
._b
64encode
(username
),
249 self
._b
64encode
(password
),
250 self
._escape
(CHALLENGETEXT
),
254 def _client_auth_successful(self
, cid
, kid
):
256 Sends a positive authentication response
258 log
.debug("Client Authentication Successful (cid=%s, kid=%s)" % (cid
, kid
))
261 "client-auth-nt %s %s" % (cid
, kid
),
266 return base64
.b64encode(s
.encode()).decode()
270 return base64
.b64decode(s
.encode()).decode()
274 return s
.replace(" ", "\ ")
276 def _find_connection(self
, common_name
):
277 with
open(OPENVPN_CONFIG
, "r") as f
:
278 for row
in csv
.reader(f
, dialect
="unix"):
279 # Skip empty rows or rows that are too short
280 if not row
or len(row
) < 5:
283 # Skip disabled connections
284 if not row
[1] == "on":
287 # Skip any net-2-net connections
288 if not row
[4] == "host":
291 # Skip if common name does not match
292 if not row
[3] == common_name
:
298 "common_name" : row
[3],
304 "totp_protocol" : row
[43],
305 "totp_status" : row
[44],
306 "totp_secret" : row
[45],
314 def _check_totp_token(self
, token
, secret
):
316 ["oathtool", "--totp", "-w", "3", "%s" % secret
],
320 # Catch any errors if we could not run the command
322 log
.error("Could not run oathtool: %s" % p
.stderr
)
326 # Reading returned tokens looking for a match
327 for line
in p
.stdout
.split(b
"\n"):
328 # Skip empty/last line(s)
332 # Decode bytes into string
335 # Return True if a token matches
343 if __name__
== "__main__":
344 parser
= argparse
.ArgumentParser(description
="OpenVPN Authenticator")
347 parser
.add_argument("--daemon", "-d", action
="store_true",
348 help="Launch as daemon in background")
349 parser
.add_argument("--verbose", "-v", action
="count", help="Be more verbose")
352 parser
.add_argument("--socket", default
="/var/run/openvpn.sock",
353 metavar
="PATH", help="Path to OpenVPN Management Socket")
355 # Parse command line arguments
356 args
= parser
.parse_args()
359 loglevel
= logging
.WARN
362 if args
.verbose
== 1:
363 loglevel
= logging
.INFO
364 elif args
.verbose
>= 2:
365 loglevel
= logging
.DEBUG
367 # Create an authenticator
368 authenticator
= OpenVPNAuthenticator(args
.socket
)
370 with daemon
.DaemonContext(
371 detach_process
=args
.daemon
,
372 stderr
=None if args
.daemon
else sys
.stderr
,
374 signal
.SIGINT
: authenticator
.terminate
,
375 signal
.SIGTERM
: authenticator
.terminate
,
378 setup_logging(daemon
=args
.daemon
, loglevel
=loglevel
)