Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
executable file 333 lines (271 sloc) 10 KB
#!__PYTHON__
#
# duo_openvpn.py
# Duo OpenVPN v1
# Copyright 2011 Duo Security, Inc.
#
# Various modifications by Jan Schaumann <jschauma@etsy.com>.
#
import os, sys, urllib, hashlib, hmac, base64, json, syslog, socket, subprocess
import tempfile, time
from https_wrapper import CertValidatingHTTPSConnection
API_RESULT_AUTH = 'auth'
API_RESULT_ALLOW = 'allow'
API_RESULT_DENY = 'deny'
API_RESULT_ENROLL = 'enroll'
# Number of seconds for which we allow a user/source-IP pair to be cached
# and not require re-authentication.
#
# Currently: 8 days
# Note: this implements an explicit loophole, turning this module into a
# one-factor authentication module for the duration of CACHE_WINDOW. This
# significantly reduces the overall security of the VPN.
#
# Please only use this if you know what you are doing.
CACHE_WINDOW = 60 * 60 * 24 * 8
# File containing user,ip,timestamp tuples
CACHE_FILE = '/var/tmp/duo_cache'
ca_certs = '__PREFIX__/share/duo/ca_certs.pem'
# Run the given fallback command; if it returns 0, then authentication was
# successful
def fallbackAuth(fallback, control):
log("Trying fallback: %s" % fallback)
try:
os.execl(fallback)
# NOTREACHED
except Exception, e:
log(str(e))
failure(control)
# Check if the given user/ip pair has been seen in the last CACHE_WINDOW
# seconds. More specifically, we check for the existence of
# user/ip/timestamp data in the CACHE_FILE, then compare the
# both timestamps to the current time.
#
# The timestamp indicates the last time the client performed a full
# authentication handshake, and it is written only when Duo authentication
# has successfully performed.
#
# If a client re-connects from the given IP within CACHE_WINDOW, then we
# return true, but do NOT update the timestamp. Otherwise, we return
# false.
#
# Returns true or false.
def cached(username, ipaddress):
f = None
n = 0
found = False
now = int(time.time())
if os.path.exists(CACHE_FILE):
try:
f = open(CACHE_FILE, "r")
for line in f.readlines():
n += 1
(user, ip, ts) = line.split(",")
if (user == username) and (ip == ipaddress):
if ((now - int(ts)) < CACHE_WINDOW):
found = True
f.close()
except (OSError, IOError), e:
log("Unable to read %s: %s\n" % (CACHE_FILE, os.strerror(e.errno)))
return False
except ValueError, e:
log("Illegal format in %s line %d\n" % (CACHE_FILE, n))
return False
return found
# Update CACHE_FILE to add or remove a user's cache info. Note that this
# implies that we do not allow multiple connections from different source
# IPs for a single user.
#
# Since we may call this function either before or after Duo
# authentication, we have to actually parse the file again. That is, we
# cannot do this work in 'cache' above.
def updateCache(username, ipaddress, action='add'):
log("Updating cache for '%s,%s'\n" % (username, ipaddress))
f = None
now = int(time.time())
newline = "%s,%s,%s\n" % (username, ipaddress, str(now))
lines = []
if action == 'add':
lines.append(newline)
if os.path.exists(CACHE_FILE):
try:
f = open(CACHE_FILE, "r")
for line in f.readlines():
(user, ip, ts) = line.split(",")
if (user != username):
lines.append(line)
f.close()
except (OSError, IOError), e:
log("Unable to read %s: %s\n" % (CACHE_FILE, os.strerror(e.errno)))
return False
try:
tmpf = tempfile.NamedTemporaryFile(dir='/var/tmp', delete=False)
tmpf.write("".join(lines))
tmpf.close()
except OSError, e:
log("Unable to create temporary file: %s\n" % os.strerror(e.errno))
return False
try:
os.rename(tmpf.name, CACHE_FILE)
except OSError, e:
log("Unable to rename %s to %s: %s\n" % (tmpf.name, CACHE_FILE, os.strerror(e.errno)))
def canonicalize(method, host, uri, params):
canon = [method.upper(), host.lower(), uri]
args = []
for key in sorted(params.keys()):
val = params[key]
arg = '%s=%s' % (urllib.quote(key, '~'), urllib.quote(val, '~'))
args.append(arg)
canon.append('&'.join(args))
return '\n'.join(canon)
def sign(ikey, skey, method, host, uri, params):
sig = hmac.new(skey, canonicalize(method, host, uri, params), hashlib.sha1)
auth = '%s:%s' % (ikey, sig.hexdigest())
return 'Basic %s' % base64.b64encode(auth)
def call(ikey, skey, host, method, path, **kwargs):
headers = {'Authorization':sign(ikey, skey, method, host, path, kwargs)}
if method in [ 'POST', 'PUT' ]:
headers['Content-type'] = 'application/x-www-form-urlencoded'
body = urllib.urlencode(kwargs, doseq=True)
uri = path
else:
body = None
uri = path + '?' + urllib.urlencode(kwargs, doseq=True)
conn = CertValidatingHTTPSConnection(host, 443, ca_certs=ca_certs)
conn.request(method, uri, body, headers)
response = conn.getresponse()
data = response.read()
conn.close()
return (response.status, response.reason, data)
def api(ikey, skey, host, method, path, **kwargs):
(status, reason, data) = call(ikey, skey, host, method, path, **kwargs)
if status != 200:
raise RuntimeError('Received %s %s: %s' % (status, reason, data))
try:
data = json.loads(data)
if data['stat'] != 'OK':
raise RuntimeError('Received error response: %s' % data)
return data['response']
except (ValueError, KeyError):
raise RuntimeError('Received bad response: %s' % data)
def log(msg):
msg = 'Duo OpenVPN: %s' % msg
syslog.syslog(msg)
def success(control, cache):
log('writing success code to %s' % control)
f = open(control, 'w')
f.write('1')
f.close()
if cache:
common_name = os.environ.get('common_name')
ipaddr = os.environ.get('ipaddr', '0.0.0.0')
updateCache(common_name, ipaddr)
sys.exit(0)
def failure(control):
log('writing failure code to %s' % control)
f = open(control, 'w')
f.write('0')
f.close()
common_name = os.environ.get('username')
updateCache(common_name, None, 'delete')
sys.exit(1)
# Note: 'username' here may actualy be the CN of the cert; whichever is
# given, it must match the Duo username.
def preauth(ikey, skey, host, control, username):
log('pre-authentication for %s' % username)
args = {
'user': username,
}
response = api(ikey, skey, host, 'POST', '/rest/v1/preauth', **args)
result = response.get('result')
if not result:
log('invalid API response: %s' % response)
failure(control)
if result == API_RESULT_AUTH:
return
status = response.get('status')
if not status:
log('invalid API response: %s' % response)
failure(control)
if result == API_RESULT_ENROLL:
log('user %s is not enrolled: %s' % (username, status))
failure(control)
elif result == API_RESULT_DENY:
log('preauth failure for %s: %s' % (username, status))
failure(control)
elif result == API_RESULT_ALLOW:
log('preauth success for %s: %s' % (username, status))
success(control, True)
else:
log('unknown preauth result: %s' % result)
failure(control)
def auth(ikey, skey, host, control, username, password, ipaddr):
log('authentication for %s' % username)
args = {
'user': username,
'factor': 'auto',
'auto': password,
'ipaddr': ipaddr
}
response = api(ikey, skey, host, 'POST', '/rest/v1/auth', **args)
result = response.get('result')
status = response.get('status')
if not result or not status:
log('invalid API response: %s' % response)
failure(control)
if result == API_RESULT_ALLOW:
log('auth success for %s: %s' % (username, status))
success(control, True)
elif result == API_RESULT_DENY:
log('auth failure for %s: %s' % (username, status))
failure(control)
else:
log('unknown auth result: %s' % result)
failure(control)
def main():
control = os.environ.get('control')
username = os.environ.get('username')
common_name = os.environ.get('common_name')
password = os.environ.get('password')
ipaddr = os.environ.get('ipaddr', '0.0.0.0')
fallback = os.environ.get('fallback')
# A 'composite' password is one that combines a single password string
# for multiple authentication plugins, for example ldap authentication
# and duo. 'Composite' passwords are formed by combining two
# passwords with a comma, ie "<ldap>,<duo>".
composite = os.environ.get('composite_password')
if composite:
if "," in password:
password = password.split(',')[-1]
else:
log("no delimiter (',') found")
failure(control)
if not control or not common_name or not password:
log('required environment variables not found')
sys.exit(1)
ikey = os.environ.get('ikey')
skey = os.environ.get('skey')
host = os.environ.get('host')
if not ikey or not skey or not host:
log('required ikey/skey/host configuration parameters not found')
failure(control)
if cached(common_name, ipaddr):
log("'%s,%s' found in cache, avoiding Duo authentication\n" % (common_name, ipaddr))
success(control, False)
# NOTREACHED
try:
preauth(ikey, skey, host, control, common_name)
auth(ikey, skey, host, control, common_name, password, ipaddr)
except (RuntimeError, socket.gaierror, socket.error), e:
# any failure here indicates a problem with Duo, so try fallback, if any
log(str(e))
if fallback:
fallbackAuth(fallback, control)
else:
failure(control)
except Exception, e:
log(str(e))
failure(control)
failure(control)
if __name__ == '__main__':
main()