├── tests ├── __init__.py ├── test_command.py ├── test_command.sh ├── test_temp_file_permissions_are_checked.py ├── shared │ └── __init__.py ├── test_ssl_public_key_pinning.py ├── test_OktaAPIAuth.py └── test_OktaOpenVPNValidator.py ├── .gitignore ├── okta.json ├── requirements.txt ├── AUTHORS.rst ├── Makefile ├── okta_openvpn.ini.inc ├── okta_pinset.py ├── defer_simple.c ├── README.md ├── README.org ├── CODE.md ├── okta_openvpn.py ├── LICENSE ├── openvpn-plugin.h └── CODE.org /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | venv/ 4 | .DS_Store 5 | cover/ 6 | .coverage 7 | okta_openvpn.ini -------------------------------------------------------------------------------- /okta.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | "authentication", 4 | "authorization", 5 | "integrationNetwork" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==14.5.14 2 | coverage==3.7.1 3 | cryptography==1.7.2 4 | mock==2.0.0 5 | nose==1.3.7 6 | urllib3==1.10.2 7 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Author: 2 | joel.franusic@okta.com 3 | 4 | Based on https://github.com/mozilla-it/duo_openvpn, written by: 5 | gdestuynder@mozilla.com 6 | -------------------------------------------------------------------------------- /tests/test_command.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import unittest 4 | 5 | 6 | class TestOktaOpenVPNCommand(unittest.TestCase): 7 | 8 | def test_true(self): 9 | self.assertEquals(True, True) 10 | 11 | def test_command(self): 12 | rv = subprocess.call(["/bin/bash", 13 | "tests/test_command.sh"]) 14 | self.assertEquals(rv, 0) 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CC := gcc 2 | CFLAGS := 3 | LDFLAGS := -fPIC -shared 4 | INSTALL := install 5 | DESTDIR := / 6 | PREFIX := /usr 7 | 8 | all: plugin 9 | 10 | plugin: defer_simple.c 11 | $(CC) $(CFLAGS) $(LDFLAGS) -I. -c defer_simple.c 12 | $(CC) $(CFLAGS) $(LDFLAGS) -Wl,-soname,defer_simple.so -o defer_simple.so defer_simple.o 13 | 14 | install: plugin 15 | mkdir -p $(DESTDIR)$(PREFIX)/lib/openvpn/plugins/ 16 | mkdir -p $(DESTDIR)/etc/openvpn/ 17 | $(INSTALL) -m755 defer_simple.so $(DESTDIR)$(PREFIX)/lib/openvpn/plugins/ 18 | $(INSTALL) -m755 okta_openvpn.py $(DESTDIR)$(PREFIX)/lib/openvpn/plugins/ 19 | $(INSTALL) -m755 okta_pinset.py $(DESTDIR)$(PREFIX)/lib/openvpn/plugins/ 20 | $(INSTALL) -m644 okta_openvpn.ini.inc $(DESTDIR)/etc/openvpn/okta_openvpn.ini 21 | 22 | clean: 23 | rm -f *.o 24 | rm -f *.so 25 | rm -f *.pyc 26 | rm -rf __pycache__ 27 | -------------------------------------------------------------------------------- /okta_openvpn.ini.inc: -------------------------------------------------------------------------------- 1 | [OktaAPI] 2 | ## The URL for your Okta instance 3 | ## (Example below) 4 | # Url: https://example.okta.com 5 | Url: 6 | ## The API Token for your Okta instace 7 | ## (Example below) 8 | # Token: 01Abcd2efGHIjKl3m4NoPQrstu5vwxYZ_AbcdefGHi 9 | Token: 10 | ## An OPTIONAL suffix to be appended to the end of user names 11 | ## before the attempting authentication against Okta. 12 | ## For example: If this was set to 'example.com', a user with a 13 | ## certificate identifiying them as 'first.last' would be authenticated 14 | ## against Okta as 'first.last@example.com'. 15 | ## (Example below) 16 | # UsernameSuffix: example.com 17 | ## Do not require usernames to come from client-side SSL certificates. 18 | ## NOT RECCOMMENDED FOR PRODUCTION ENVIRONMENTS 19 | ## (Example below) 20 | # AllowUntrustedUsers: True 21 | ## Configure how often poll Okta for results of an Okta Verify Push 22 | ## Values below are what are set by default: 23 | # MFAPushMaxRetries: 20 24 | # MFAPushDelaySeconds: 3 25 | -------------------------------------------------------------------------------- /tests/test_command.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Create configuration file 4 | OKTA_URL='https://mocked-okta-api.herokuapp.com' 5 | OKTA_TOKEN='mocked-token-for-openvpn' 6 | config_file='okta_openvpn.ini' 7 | (echo "[OktaAPI]"; 8 | echo "Url: ${OKTA_URL}"; 9 | echo "Token: ${OKTA_TOKEN}") > $config_file 10 | 11 | # Export variables that OpenVPN would be exporting in deferred mode 12 | export common_name='user_MFA_REQUIRED@example.com' 13 | export password='Testing1123456' 14 | export untrusted_ip='10.0.0.1' 15 | export auth_control_file="$(mktemp -t okta_openvpn.XXXXX)" 16 | # $ echo -n | openssl s_client -connect mocked-okta-api.herokuapp.com:443 | openssl x509 -noout -pubkey | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | base64 17 | export assert_pin='2hLOYtjSs5a3Jxy5GVM5EMuqa3JHhR6gM99EoaDauug=' 18 | 19 | 20 | python okta_openvpn.py 21 | 22 | # Save the return value of the script 23 | rv=$? 24 | # Save the contents of the auth control file 25 | acf_contents="$(cat $auth_control_file)" 26 | 27 | # Cleanup 28 | rm $auth_control_file 29 | rm $config_file 30 | 31 | if [ $rv -eq 0 ] && [ $acf_contents -eq 1 ]; then 32 | exit 0; 33 | else 34 | exit 1; 35 | fi 36 | -------------------------------------------------------------------------------- /okta_pinset.py: -------------------------------------------------------------------------------- 1 | # # Here is how a pin like those below may be generated: 2 | # echo -n | openssl s_client -connect example.com:443 | 3 | # openssl x509 -noout -pubkey | 4 | # openssl rsa -pubin -outform der | 5 | # openssl dgst -sha256 -binary | base64 6 | okta_pinset = [ 7 | # okta.com 8 | 'r5EfzZxQVvQpKo3AgYRaT7X2bDO/kj3ACwmxfdT2zt8=', 9 | 'MaqlcUgk2mvY/RFSGeSwBRkI+rZ6/dxe/DuQfBT/vnQ=', 10 | '72G5IEvDEWn+EThf3qjR7/bQSWaS2ZSLqolhnO6iyJI=', 11 | 'rrV6CLCCvqnk89gWibYT0JO6fNQ8cCit7GGoiVTjCOg=', 12 | # oktapreview.com 13 | 'jZomPEBSDXoipA9un78hKRIeN/+U4ZteRaiX8YpWfqc=', 14 | 'axSbM6RQ+19oXxudaOTdwXJbSr6f7AahxbDHFy3p8s8=', 15 | 'SE4qe2vdD9tAegPwO79rMnZyhHvqj3i5g1c2HkyGUNE=', 16 | 'ylP0lMLMvBaiHn0ihLxHjzvlPVQNoyQ+rMiaj0da/Pw=', 17 | # internal testing 18 | 'W2qOJ9F9eo3CYHzL5ZIjYEizINI1cUPEb7yD45ihTXg=', 19 | 'PJ1QGTlW5ViFNhswMsYKp4X8C7KdG8nDW4ZcXLmYMyI=', 20 | '5LlRWGTBVjpfNXXU5T7cYVUbOSPcgpMgdjaWd/R9Leg=', 21 | 'lpaMLlEsp7/dVZoeWt3f9ciJIMGimixAIaKNsn9/bCY=', 22 | # internal testing 23 | 'Uit61pzomPOIy0svL1z4OUx3FMBr9UWQVdyG7ZlSLK8=', 24 | 'Ul2vkypIA80/JDebYsXq8FGdtmtrx5WJAAHDlSwWOes=', 25 | 'rx1UuNLIkJs53Jd60G/zY947XcDIf56JyM/yFJyR/GE=', 26 | 'VvpiE4cl60BvOU8X4AfkWeUPsmRUSh/nVbJ2rnGDZHI=', 27 | ] 28 | -------------------------------------------------------------------------------- /defer_simple.c: -------------------------------------------------------------------------------- 1 | /* 2 | * This program is free software; you can redistribute it and/or modify 3 | * it under the terms of the GNU General Public License version 2 4 | * as published by the Free Software Foundation. 5 | * 6 | * This program is distributed in the hope that it will be useful, 7 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | * GNU General Public License for more details. 10 | * 11 | * You should have received a copy of the GNU General Public License 12 | * along with this program (see the file COPYING included with this 13 | * distribution); if not, write to the Free Software Foundation, Inc., 14 | * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 15 | * 16 | * Copyright (C) 2002-2010 OpenVPN Technologies, Inc. (defer/simple.c) 17 | * Copyright (C) 2014 Mozilla Corporation 18 | */ 19 | 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include 26 | #include 27 | #include 28 | #include 29 | 30 | struct context { 31 | char *script_path; 32 | }; 33 | 34 | void handle_sigchld(int sig) 35 | { 36 | while(waitpid((pid_t)(-1), 0, WNOHANG) > 0) {} 37 | } 38 | 39 | static int 40 | generic_deferred_handler(char *script_path, const char * envp[]) 41 | { 42 | int pid; 43 | struct sigaction sa; 44 | char *argv[] = {script_path, 0}; 45 | 46 | sa.sa_handler = &handle_sigchld; 47 | sigemptyset(&sa.sa_mask); 48 | sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; 49 | 50 | if (sigaction(SIGCHLD, &sa, 0) == -1) { 51 | return OPENVPN_PLUGIN_FUNC_ERROR; 52 | } 53 | 54 | pid = fork(); 55 | 56 | if (pid < 0) { 57 | return OPENVPN_PLUGIN_FUNC_ERROR; 58 | } 59 | 60 | if (pid > 0) { 61 | return OPENVPN_PLUGIN_FUNC_DEFERRED; 62 | } 63 | 64 | execve(argv[0], &argv[0], (char *const*)envp); 65 | exit(127); 66 | } 67 | 68 | OPENVPN_EXPORT int 69 | openvpn_plugin_func_v2(openvpn_plugin_handle_t handle, const int type, const char *argv[], 70 | const char *envp[], void *per_client_context, 71 | struct openvpn_plugin_string_list **return_list) 72 | { 73 | struct context *ctx = (struct context *) handle; 74 | 75 | if (type == OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY) { 76 | return generic_deferred_handler(ctx->script_path, envp); 77 | } else { 78 | return OPENVPN_PLUGIN_FUNC_ERROR; 79 | } 80 | } 81 | 82 | OPENVPN_EXPORT openvpn_plugin_handle_t 83 | openvpn_plugin_open_v2(unsigned int *type_mask, const char *argv[], const char *envp[], 84 | struct openvpn_plugin_string_list **return_list) 85 | { 86 | struct context *ctx; 87 | 88 | ctx = (struct context *) calloc(1, sizeof(struct context)); 89 | 90 | if (argv[1]) { 91 | ctx->script_path = strdup(argv[1]); 92 | } 93 | *type_mask = OPENVPN_PLUGIN_MASK(OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY); 94 | 95 | return (openvpn_plugin_handle_t) ctx; 96 | } 97 | 98 | OPENVPN_EXPORT void 99 | openvpn_plugin_close_v1(openvpn_plugin_handle_t handle) 100 | { 101 | struct context *ctx = (struct context *) handle; 102 | 103 | free(ctx->script_path); 104 | free(ctx); 105 | } 106 | -------------------------------------------------------------------------------- /tests/test_temp_file_permissions_are_checked.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | import unittest 5 | 6 | from mock import MagicMock 7 | import urllib3 8 | 9 | from okta_openvpn import OktaAPIAuth 10 | from okta_openvpn import OktaOpenVPNValidator 11 | from okta_openvpn import ControlFilePermissionsError 12 | 13 | from tests.shared import OktaTestCase 14 | from tests.shared import MockEnviron 15 | 16 | 17 | class TestTempFilePermissions(OktaTestCase): 18 | def setUp(self): 19 | super(TestTempFilePermissions, self).setUp() 20 | 21 | self.expected_messages = [ 22 | 'efusing to authenticate', 23 | 'must not be', 24 | 'writable', 25 | ] 26 | 27 | def test_control_file_has_bad_permissions(self): 28 | cfg = self.config 29 | 30 | tmp = tempfile.NamedTemporaryFile() 31 | 32 | os.chmod(tmp.name, 0777) 33 | 34 | env = MockEnviron({ 35 | 'common_name': self.config['username'], 36 | 'password': self.config['password'], 37 | 'auth_control_file': tmp.name, 38 | 'assert_pin': self.herokuapp_dot_com_pin, 39 | }) 40 | validator = OktaOpenVPNValidator() 41 | validator.site_config = cfg 42 | validator.env = env 43 | 44 | self.assertRaises(ControlFilePermissionsError, validator.run) 45 | last_error = self.okta_log_messages['critical'][-1:][0] 46 | for msg in self.expected_messages: 47 | self.assertIn(msg, last_error) 48 | tmp.close() 49 | 50 | def test_control_file_bad_permissions_permutations(self): 51 | cfg = self.config 52 | modes = [ 53 | 0606, 54 | 0660, 55 | 0622, 56 | ] 57 | for mode in modes: 58 | tmp = tempfile.NamedTemporaryFile() 59 | os.chmod(tmp.name, mode) 60 | 61 | env = MockEnviron({ 62 | 'common_name': self.config['username'], 63 | 'password': self.config['password'], 64 | 'auth_control_file': tmp.name, 65 | 'assert_pin': self.herokuapp_dot_com_pin, 66 | }) 67 | validator = OktaOpenVPNValidator() 68 | validator.site_config = cfg 69 | validator.env = env 70 | self.assertRaises(ControlFilePermissionsError, validator.run) 71 | tmp.close() 72 | 73 | def test_control_file_directory_has_bad_permissions(self): 74 | cfg = self.config 75 | 76 | tmp_dir = tempfile.mkdtemp() 77 | tmp = tempfile.NamedTemporaryFile(dir=tmp_dir) 78 | os.chmod(tmp_dir, 0777) 79 | env = MockEnviron({ 80 | 'common_name': self.config['username'], 81 | 'password': self.config['password'], 82 | 'auth_control_file': tmp.name, 83 | 'assert_pin': self.herokuapp_dot_com_pin, 84 | }) 85 | validator = OktaOpenVPNValidator() 86 | validator.site_config = cfg 87 | validator.env = env 88 | 89 | msgs = self.expected_messages 90 | msgs.append('directory containing') 91 | 92 | self.assertRaises(ControlFilePermissionsError, validator.run) 93 | last_error = self.okta_log_messages['critical'][-1:][0] 94 | for msg in msgs: 95 | self.assertIn(msg, last_error) 96 | tmp.close() 97 | os.rmdir(tmp_dir) 98 | -------------------------------------------------------------------------------- /tests/shared/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import logging 4 | from okta_openvpn import OktaAPIAuth 5 | 6 | 7 | class OktaTestCase(unittest.TestCase): 8 | 9 | @classmethod 10 | def setUpClass(cls): 11 | super(OktaTestCase, cls).setUpClass() 12 | okta_log = logging.getLogger('okta_openvpn') 13 | cls._okta_log_handler = MockLoggingHandler(level='DEBUG') 14 | okta_log.addHandler(cls._okta_log_handler) 15 | cls.okta_log_messages = cls._okta_log_handler.messages 16 | # https://urllib3.readthedocs.org/en/latest/security.html#insecurerequestwarning 17 | logging.captureWarnings(True) 18 | 19 | def setUp(self): 20 | super(OktaTestCase, self).setUp() 21 | self._okta_log_handler.reset() # So each test is independent 22 | # # Here is how a pin like those below may be generated: 23 | # echo -n | openssl s_client -connect example.com:443 | 24 | # openssl x509 -noout -pubkey | 25 | # openssl rsa -pubin -outform der | 26 | # openssl dgst -sha256 -binary | base64 27 | self.example_dot_com_pin = ( 28 | 'wiviOfSDwIlXvBBiGcwtOsGjCN+73Qo2Xxe5NRI0zwA=') 29 | self.herokuapp_dot_com_pin = ( 30 | '2hLOYtjSs5a3Jxy5GVM5EMuqa3JHhR6gM99EoaDauug=') 31 | self.okta_url = os.environ.get( 32 | 'okta_url_mock', 33 | 'https://mocked-okta-api.herokuapp.com') 34 | self.okta_token = 'mocked-token-for-openvpn' 35 | self.username_prefix = 'user_MFA_REQUIRED' 36 | self.username_suffix = 'example.com' 37 | self.config = { 38 | 'okta_url': self.okta_url, 39 | 'okta_token': self.okta_token, 40 | 'username': "{}@{}".format(self.username_prefix, 41 | self.username_suffix), 42 | 'password': 'Testing1123456', 43 | 'client_ipaddr': '4.2.2.2', 44 | } 45 | self.mfa_push_delay_secs = 1 46 | 47 | 48 | class ThrowsErrorOktaAPI(OktaAPIAuth): 49 | def __init__(self, *args, **kwargs): 50 | raise Exception() 51 | 52 | 53 | class MockLoggingHandler(logging.Handler): 54 | """Mock logging handler to check for expected logs. 55 | 56 | Messages are available from an instance's ``messages`` dict, 57 | in order, indexed by a lowercase log level string 58 | (e.g., 'debug', 'info', etc.). 59 | """ 60 | 61 | def __init__(self, *args, **kwargs): 62 | self.messages = {'debug': [], 'info': [], 'warning': [], 'error': [], 63 | 'critical': []} 64 | super(MockLoggingHandler, self).__init__(*args, **kwargs) 65 | 66 | def emit(self, record): 67 | "Store a message from ``record`` in the instance's ``messages`` dict." 68 | self.acquire() 69 | try: 70 | self.messages[record.levelname.lower()].append(record.getMessage()) 71 | finally: 72 | self.release() 73 | 74 | def reset(self): 75 | self.acquire() 76 | try: 77 | for message_list in self.messages.values(): 78 | message_list = [] 79 | finally: 80 | self.release() 81 | 82 | 83 | class MockEnviron: 84 | def __init__(self, values): 85 | self.values = values 86 | 87 | def get(self, k, v=None): 88 | if k in self.values: 89 | return self.values[k] 90 | elif v: 91 | return v 92 | else: 93 | return None 94 | -------------------------------------------------------------------------------- /tests/test_ssl_public_key_pinning.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tempfile 3 | import unittest 4 | 5 | from mock import MagicMock 6 | import urllib3 7 | 8 | from okta_openvpn import OktaAPIAuth 9 | from okta_openvpn import OktaOpenVPNValidator 10 | from okta_openvpn import PinError 11 | 12 | from tests.shared import OktaTestCase 13 | from tests.shared import MockEnviron 14 | from tests.shared import MockLoggingHandler 15 | 16 | 17 | class TestOktaAPIAuthTLSPinning(OktaTestCase): 18 | def test_connect_to_unencrypted_server(self): 19 | config = self.config 20 | config['okta_url'] = 'http://example.com' 21 | okta = OktaAPIAuth(**config) 22 | self.assertRaises(urllib3.exceptions.PoolError, okta.preauth) 23 | 24 | def test_connect_to_encrypted_but_unintended_server(self): 25 | config = self.config 26 | config['okta_url'] = 'https://example.com' 27 | okta = OktaAPIAuth(**config) 28 | self.assertRaises(PinError, okta.preauth) 29 | 30 | def test_connect_to_unintended_server_writes_0_to_control_file(self): 31 | cfg = self.config 32 | cfg['okta_url'] = 'https://example.com' 33 | 34 | tmp = tempfile.NamedTemporaryFile() 35 | env = MockEnviron({ 36 | 'common_name': self.config['username'], 37 | 'password': self.config['password'], 38 | 'auth_control_file': tmp.name, 39 | }) 40 | 41 | validator = OktaOpenVPNValidator() 42 | validator.site_config = cfg 43 | validator.env = env 44 | 45 | validator.run() 46 | 47 | self.assertFalse(validator.user_valid) 48 | tmp.file.seek(0) 49 | rv = tmp.file.read() 50 | self.assertEquals(rv, '0') 51 | 52 | def test_connect_to_okta_with_good_pins(self): 53 | config = self.config 54 | config['okta_url'] = 'https://example.okta.com' 55 | okta = OktaAPIAuth(**config) 56 | result = okta.preauth() 57 | # This is what we'll get since we're sending an invalid token: 58 | self.assertIn('errorSummary', result) 59 | self.assertEquals(result['errorSummary'], 'Invalid token provided') 60 | 61 | def test_connect_to_example_with_good_pin(self): 62 | config = self.config 63 | config['assert_pinset'] = [self.herokuapp_dot_com_pin] 64 | okta = OktaAPIAuth(**config) 65 | result = okta.preauth() 66 | self.assertIn('status', result) 67 | self.assertEquals(result['status'], 'MFA_REQUIRED') 68 | 69 | def test_connect_to_example_with_bad_pin(self): 70 | config = self.config 71 | config['assert_pinset'] = ['not-a-sha256'] 72 | okta = OktaAPIAuth(**config) 73 | self.assertRaises(PinError, okta.preauth) 74 | 75 | def test_bad_pin_log_message(self): 76 | config = self.config 77 | config['assert_pinset'] = ['not-a-sha256'] 78 | okta = OktaAPIAuth(**config) 79 | self.assertRaises(PinError, okta.preauth) 80 | last_error = self.okta_log_messages['critical'][-1:][0] 81 | messages = [ 82 | 'efusing to authenticate', 83 | 'mocked-okta-api.herokuapp.com', 84 | 'TLS public key pinning check', 85 | 'lease contact support@okta.com', 86 | ] 87 | for msg in messages: 88 | self.assertIn(msg, last_error) 89 | 90 | def test_validate_conn_checks_is_verified(self): 91 | from okta_openvpn import PublicKeyPinsetConnectionPool 92 | pool = PublicKeyPinsetConnectionPool('example.com', 443) 93 | conn = MagicMock() 94 | conn.is_verified = False 95 | self.assertRaises(Exception, pool._validate_conn, conn) 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This is a plugin for OpenVPN (Community Edition) that authenticates users directly against Okta, with support for MFA. 4 | 5 | Note: This plugin does not work with OpenVPN Access Server (OpenVPN-AS) 6 | 7 | 8 | # Requirements 9 | 10 | This plugin requires that OpenVPN Community Edition be configured or used in the following ways: 11 | 12 | 1. OpenVPN must be configured to call plugins via a deferred call. 13 | 2. By default, OpenVPN clients *must* authenticate using client SSL certificates. 14 | 3. If authenticating using MFA, the end user will authenticate by appending their six-digit MFA token to the end of their password. 15 | 16 | For example, if a user's password is `correcthorsebatterystaple` and their six-digit MFA token is `123456`, they would use `correcthorsebatterystaple123456` as the password for their OpenVPN client 17 | 18 | 19 | # Setup and Configuration 20 | 21 | 22 | ## Verify the GPG signature on this repository 23 | 24 | The source code for this plugin is signed using [GPG](https://gnupg.org/). 25 | 26 | It is recommended that this plugin be verified using the git tag -v $TAGNAME command. 27 | 28 | For example, to verify the v0.10.0 tag, use the command below: 29 | 30 | ```shell 31 | $ git tag -v v0.10.0 32 | ``` 33 | 34 | 35 | ## Compile the C plugin 36 | 37 | Compile the C plugin from this directory using this command: 38 | 39 | ```shell 40 | $ make 41 | ``` 42 | 43 | 44 | ## Install required Python packages 45 | 46 | The Python code in this project depends on the following Python packages: 47 | 48 | - urllib3 49 | - cryptography 50 | - certifi 51 | 52 | If you use [pip](https://en.wikipedia.org/wiki/Pip_%28package_manager%29) to manage your Python packages, you can install these requirements using this command: 53 | 54 | ```shell 55 | $ sudo pip install urllib3 cryptography certifi 56 | ``` 57 | 58 | If the pip command above doesn't work, you may need to install pip or the development software that cryptography depends on. 59 | 60 | This project also comes with a requirements.txt file that works nicely with pip: 61 | 62 | ```shell 63 | $ sudo pip install -r requirements.txt 64 | ``` 65 | 66 | 67 | ## Install the Okta OpenVPN plugin 68 | 69 | You have two options to install the Okta OpenVPN plugin: 70 | 71 | 1. For default setups, use make install to run the install for you. 72 | 2. For custom setups, follow the manual installation instructions below. 73 | 74 | If you have a default OpenVPN setup, where plugins are stored in /usr/lib/openvpn/plugins and configuration files are stored in /etc/openvpn, then you can use the make install command to install the Okta OpenVPN plugin: 75 | 76 | ```shell 77 | $ sudo make install 78 | ``` 79 | 80 | 81 | ## Manually installing the Okta OpenVPN plugin 82 | 83 | If you have a custom setup, follow the instructions below to install the C plugin and Python scripts that constitute the Okta OpenVPN plugin. 84 | 85 | 86 | ### Manually installing the C Plugin 87 | 88 | To manually install the C plugin, copy the defer\_simple.so file to the location where your OpenVPN plugins are stored. 89 | 90 | 91 | ### Manually installing the Python script 92 | 93 | To manually install the Python scripts, copy the okta\_openvpn.py, okta\_pinset.py, and okta\_openvpn.ini files to the location where your OpenVPN plugin scripts are stored. 94 | 95 | 96 | ## Make sure that OpenVPN has a tempory directory 97 | 98 | In OpenVPN, the "deferred plugin" model requires the use of temporary files to work. It is recommended that these temporary files be stored in a directory that only OpenVPN has access to. The default location for this directory is /etc/openvpn/tmp. If this directory doesn't exist, create it using this command: 99 | 100 | ```shell 101 | $ sudo mkdir /etc/openvpn/tmp 102 | ``` 103 | 104 | Use the [chown](https://en.wikipedia.org/wiki/Chown) and [chmod](https://en.wikipedia.org/wiki/Chmod) commands to set permissions approprate to your setup. 105 | 106 | 107 | ## Configure the Okta OpenVPN plugin 108 | 109 | The Okta OpenVPN plugin is configured via the okta\_openvpn.ini file. You **must** update this file with the configuration options for your Okta organization for the plugin to work. 110 | 111 | If you installed the Okta OpenVPN plugin to the default location, run this command to edit your configuration file. 112 | 113 | ```shell 114 | $ sudo $EDITOR /etc/openvpn/okta_openvpn.ini 115 | ``` 116 | 117 | 118 | ## Configure OpenVPN to use the C Plugin 119 | 120 | Set up OpenVPN to call the Okta plugin by adding the following lines to your OpenVPN server.conf configuration file: 121 | 122 | ```ini 123 | plugin /usr/lib/openvpn/plugins/defer_simple.so /usr/lib/openvpn/plugins/okta_openvpn.py 124 | tmp-dir "/etc/openvpn/tmp" 125 | ``` 126 | 127 | The default location for OpenVPN configuration files is /etc/openvpn/server.conf 128 | 129 | 130 | # Testing 131 | 132 | The code in okta\_openvpn.py has 100% test coverage. Tests are run using the "nosetests" command. 133 | 134 | Run the commands below to set up an environment for testing: 135 | 136 | ```shell 137 | $ virtualenv venv 138 | $ source venv/bin/activate 139 | $ pip install -r requirements.txt 140 | ``` 141 | 142 | Once that is done, run the tests with the nosetests command: 143 | 144 | ```shell 145 | $ nosetests 146 | ``` 147 | 148 | To generate a code-coverage report on the tests, run nosetests with the following flags: 149 | 150 | ```shell 151 | $ nosetests --with-coverage --cover-html 152 | ``` 153 | 154 | View the coverage reports by opening the cover/index.html in your favorite text editor. 155 | 156 | 157 | # Contact 158 | 159 | Updates or corrections to this document are very welcome. Feel free to send me [pull requests](https://help.github.com/articles/using-pull-requests/) with suggestions. 160 | 161 | Additionally, please send me comments or questions via email: joel.franusic@okta.com 162 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | # This is a file written in Emacs and authored using org-mode (http://orgmode.org/) 2 | # The "README.md" file is generated from this file by running the 3 | # "M-x org-md-export-to-markdown" command from inside of Emacs. 4 | # 5 | # Don't render a Table of Contents 6 | #+OPTIONS: toc:nil 7 | # Don't render section numbers 8 | #+OPTIONS: num:nil 9 | # Turn of subscript parsing: http://super-user.org/wordpress/2012/02/02/how-to-get-rid-of-subscript-annoyance-in-org-mode/comment-page-1/ 10 | #+OPTIONS: ^:{} 11 | * Introduction 12 | This is a plugin for OpenVPN (Community Edition) that authenticates 13 | users directly against Okta, with support for MFA. 14 | 15 | #+BEGIN_EXAMPLE 16 | Note: This plugin does not work with OpenVPN Access Server (OpenVPN-AS) 17 | #+END_EXAMPLE 18 | 19 | * Requirements 20 | This plugin requires that OpenVPN Community Edition be configured or 21 | used in the following ways: 22 | 23 | 1. OpenVPN must be configured to call plugins via a deferred call. 24 | 2. By default, OpenVPN clients /must/ authenticate using client SSL 25 | certificates. 26 | 3. If authenticating using MFA, the end user will authenticate by 27 | appending their six-digit MFA token to the end of their password. 28 | 29 | For example, if a user's password is =correcthorsebatterystaple= and 30 | their six-digit MFA token is =123456=, they would use 31 | =correcthorsebatterystaple123456= as the password for their OpenVPN 32 | client 33 | 34 | * Setup and Configuration 35 | ** Verify the GPG signature on this repository 36 | The source code for this plugin is signed using [[https://gnupg.org/][GPG]]. 37 | 38 | It is recommended that this plugin be verified using the 39 | git tag -v $TAGNAME command. 40 | 41 | For example, to verify the v0.10.0 tag, use the command below: 42 | 43 | #+BEGIN_SRC shell 44 | $ git tag -v v0.10.0 45 | #+END_SRC 46 | 47 | ** Compile the C plugin 48 | Compile the C plugin from this directory using this command: 49 | #+BEGIN_SRC shell 50 | $ make 51 | #+END_SRC 52 | ** Install required Python packages 53 | The Python code in this project depends on the following Python packages: 54 | - urllib3 55 | - cryptography 56 | - certifi 57 | 58 | If you use [[https://en.wikipedia.org/wiki/Pip_%28package_manager%29][pip]] to manage your Python packages, you can install 59 | these requirements using this command: 60 | #+BEGIN_SRC shell 61 | $ sudo pip install urllib3 cryptography certifi 62 | #+END_SRC 63 | 64 | If the pip command above doesn't work, you may need to install pip 65 | or the development software that cryptography depends on. 66 | 67 | 68 | This project also comes with a requirements.txt file 69 | that works nicely with pip: 70 | 71 | #+BEGIN_SRC shell 72 | $ sudo pip install -r requirements.txt 73 | #+END_SRC 74 | ** Install the Okta OpenVPN plugin 75 | You have two options to install the Okta OpenVPN plugin: 76 | 1. For default setups, use make install to run the install for you. 77 | 2. For custom setups, follow the manual installation instructions below. 78 | 79 | If you have a default OpenVPN setup, 80 | where plugins are stored in /usr/lib/openvpn/plugins 81 | and configuration files are stored in /etc/openvpn, then you can use the 82 | make install command to install the Okta OpenVPN 83 | plugin: 84 | 85 | #+BEGIN_SRC shell 86 | $ sudo make install 87 | #+END_SRC 88 | ** Manually installing the Okta OpenVPN plugin 89 | If you have a custom setup, 90 | follow the instructions below to install 91 | the C plugin and Python scripts that constitute the Okta OpenVPN plugin. 92 | *** Manually installing the C Plugin 93 | To manually install the C plugin, copy the defer_simple.so file to the location where your OpenVPN plugins are stored. 94 | *** Manually installing the Python script 95 | To manually install the Python scripts, copy the okta_openvpn.py, 96 | okta_pinset.py, 97 | and okta_openvpn.ini files to the location where your OpenVPN plugin scripts are stored. 98 | ** Make sure that OpenVPN has a tempory directory 99 | In OpenVPN, the "deferred plugin" model requires the use of 100 | temporary files to work. 101 | It is recommended that these temporary files be stored in a directory that only OpenVPN has access to. 102 | The default location for this directory is 103 | /etc/openvpn/tmp. If this directory doesn't exist, 104 | create it using this command: 105 | #+BEGIN_SRC shell 106 | $ sudo mkdir /etc/openvpn/tmp 107 | #+END_SRC 108 | Use the [[https://en.wikipedia.org/wiki/Chown][chown]] and [[https://en.wikipedia.org/wiki/Chmod][chmod]] commands to set permissions approprate to your setup. 109 | ** Configure the Okta OpenVPN plugin 110 | The Okta OpenVPN plugin is configured via the okta_openvpn.ini file. 111 | You *must* update this file with the configuration options for your Okta organization for the plugin to work. 112 | 113 | If you installed the Okta OpenVPN plugin to the default location, 114 | run this command to edit your configuration file. 115 | 116 | #+BEGIN_SRC shell 117 | $ sudo $EDITOR /etc/openvpn/okta_openvpn.ini 118 | #+END_SRC 119 | ** Configure OpenVPN to use the C Plugin 120 | Set up OpenVPN to call the Okta plugin by adding the following 121 | lines to your OpenVPN server.conf configuration file: 122 | 123 | #+BEGIN_SRC ini 124 | plugin /usr/lib/openvpn/plugins/defer_simple.so /usr/lib/openvpn/plugins/okta_openvpn.py 125 | tmp-dir "/etc/openvpn/tmp" 126 | #+END_SRC 127 | The default location for OpenVPN configuration files is /etc/openvpn/server.conf 128 | * Testing 129 | The code in okta_openvpn.py has 100% test coverage. Tests are run using the "nosetests" command. 130 | 131 | Run the commands below to set up an environment for testing: 132 | 133 | #+BEGIN_SRC shell 134 | $ virtualenv venv 135 | $ source venv/bin/activate 136 | $ pip install -r requirements.txt 137 | #+END_SRC 138 | 139 | Once that is done, run the tests with the nosetests 140 | command: 141 | 142 | #+BEGIN_SRC shell 143 | $ nosetests 144 | #+END_SRC 145 | 146 | To generate a code-coverage report on the tests, run 147 | nosetests with the following flags: 148 | 149 | #+BEGIN_SRC shell 150 | $ nosetests --with-coverage --cover-html 151 | #+END_SRC 152 | 153 | View the coverage reports by opening the cover/index.html in your favorite text editor. 154 | * Contact 155 | Updates or corrections to this document are very welcome. Feel free 156 | to send me [[https://help.github.com/articles/using-pull-requests/][pull requests]] with suggestions. 157 | 158 | # In a (perhaps fruitless) effort to avoid getting more spam, I've 159 | # encoded my email address using HTML entities. 160 | Additionally, please send me comments or questions via email: joel.franusic@okta.com 161 | 162 | * Worklog :noexport: 163 | ** Installing on macOS 164 | http://stackoverflow.com/a/33125400/3191847 165 | -------------------------------------------------------------------------------- /tests/test_OktaAPIAuth.py: -------------------------------------------------------------------------------- 1 | from mock import MagicMock 2 | 3 | from okta_openvpn import OktaAPIAuth 4 | from tests.shared import MockLoggingHandler 5 | from tests.shared import OktaTestCase 6 | 7 | 8 | class TestOktaAPIAuth(OktaTestCase): 9 | def setUp(self): 10 | super(TestOktaAPIAuth, self).setUp() 11 | self.config['assert_pinset'] = [self.herokuapp_dot_com_pin] 12 | 13 | def test_okta_url_cleaned(self): 14 | config = self.config 15 | url_with_trailing_slash = "{}/".format(self.okta_url) 16 | config['okta_url'] = url_with_trailing_slash 17 | okta = OktaAPIAuth(**config) 18 | auth = okta.auth() 19 | self.assertEquals(auth, True) 20 | 21 | url_with_path = "{}/api/v1".format(self.okta_url) 22 | config['okta_url'] = url_with_path 23 | okta = OktaAPIAuth(**config) 24 | auth = okta.auth() 25 | self.assertEquals(auth, True) 26 | 27 | # OktaAPIAuth.auth() tests: 28 | def test_username_empty(self): 29 | config = self.config 30 | config['username'] = '' 31 | okta = OktaAPIAuth(**config) 32 | auth = okta.auth() 33 | self.assertEquals(auth, False) 34 | last_error = self.okta_log_messages['info'][-1:][0] 35 | self.assertIn('Missing username or password', last_error) 36 | 37 | def test_username_None(self): 38 | config = self.config 39 | config['username'] = None 40 | okta = OktaAPIAuth(**config) 41 | auth = okta.auth() 42 | self.assertEquals(auth, False) 43 | last_error = self.okta_log_messages['info'][-1:][0] 44 | self.assertIn('Missing username or password', last_error) 45 | 46 | def test_password_empty(self): 47 | config = self.config 48 | config['password'] = '' 49 | okta = OktaAPIAuth(**config) 50 | auth = okta.auth() 51 | self.assertEquals(auth, False) 52 | last_error = self.okta_log_messages['info'][-1:][0] 53 | self.assertIn('Missing username or password', last_error) 54 | 55 | def test_password_None(self): 56 | config = self.config 57 | config['password'] = None 58 | okta = OktaAPIAuth(**config) 59 | auth = okta.auth() 60 | self.assertEquals(auth, False) 61 | last_error = self.okta_log_messages['info'][-1:][0] 62 | self.assertIn('Missing username or password', last_error) 63 | 64 | def test_invalid_no_token(self): 65 | config = self.config 66 | config['password'] = 'Testing1' 67 | okta = OktaAPIAuth(**config) 68 | auth = okta.auth() 69 | self.assertEquals(auth, False) 70 | last_error = self.okta_log_messages['info'][-1:][0] 71 | self.assertIn('No second factor found for username', last_error) 72 | 73 | def test_invalid_url(self): 74 | config = self.config 75 | config['okta_url'] = 'http://127.0.0.1:86753' 76 | okta = OktaAPIAuth(**config) 77 | auth = okta.auth() 78 | self.assertEquals(auth, False) 79 | last_error = self.okta_log_messages['error'][-1:][0] 80 | self.assertIn('Error connecting to the Okta API', last_error) 81 | 82 | def test_invalid_password(self): 83 | config = self.config 84 | config['username'] = 'fake_user@example.com' 85 | config['password'] = 'BADPASSWORD123456' 86 | okta = OktaAPIAuth(**config) 87 | auth = okta.auth() 88 | self.assertEquals(auth, False) 89 | last_error = self.okta_log_messages['info'][-1:][0] 90 | expected = 'pre-authentication failed: Authentication failed' 91 | self.assertIn(expected, last_error) 92 | 93 | def test_valid_user_no_mfa(self): 94 | config = self.config 95 | config['username'] = 'Fox.Mulder@ic.fbi.example.com' 96 | config['password'] = 'trustno1' 97 | okta = OktaAPIAuth(**config) 98 | auth = okta.auth() 99 | self.assertEquals(auth, True) 100 | last_error = self.okta_log_messages['info'][-1:][0] 101 | self.assertIn('authenticated without MFA', last_error) 102 | 103 | def test_valid_user_must_enroll_mfa(self): 104 | config = self.config 105 | config['username'] = 'user_MFA_ENROLL@example.com' 106 | okta = OktaAPIAuth(**config) 107 | auth = okta.auth() 108 | self.assertEquals(auth, False) 109 | last_error = self.okta_log_messages['info'][-1:][0] 110 | self.assertIn('needs to enroll first', last_error) 111 | 112 | def test_valid_token(self): 113 | config = self.config 114 | okta = OktaAPIAuth(**config) 115 | auth = okta.auth() 116 | self.assertEquals(auth, True) 117 | last_error = self.okta_log_messages['info'][-1:][0] 118 | self.assertIn('now authenticated with MFA via Okta API', last_error) 119 | 120 | def test_invalid_token(self): 121 | config = self.config 122 | config['password'] = 'Testing1654321' 123 | okta = OktaAPIAuth(**config) 124 | auth = okta.auth() 125 | self.assertEquals(auth, False) 126 | last_error = self.okta_log_messages['debug'][-1:][0] 127 | self.assertIn('MFA token authentication failed', last_error) 128 | 129 | def test_password_expired(self): 130 | config = self.config 131 | config['username'] = 'user_PASSWORD_EXPIRED@example.com' 132 | okta = OktaAPIAuth(**config) 133 | auth = okta.auth() 134 | self.assertEquals(auth, False) 135 | last_error = self.okta_log_messages['info'][-1:][0] 136 | self.assertIn('is not allowed to authenticate', last_error) 137 | 138 | def test_unexpected_error(self): 139 | config = self.config 140 | okta = OktaAPIAuth(**config) 141 | 142 | def doauth_fail(a, b): 143 | raise Exception('Mocked exception') 144 | 145 | okta.doauth = doauth_fail 146 | auth = okta.auth() 147 | self.assertEquals(auth, False) 148 | last_error = self.okta_log_messages['error'][-1:][0] 149 | self.assertIn('Unexpected error with the Okta API', last_error) 150 | 151 | def test_user_agent_set(self): 152 | config = self.config 153 | okta = OktaAPIAuth(**config) 154 | okta.pool = MagicMock() 155 | 156 | class Urlopen_Mock: 157 | data = '{}' 158 | okta.pool.urlopen.return_value = Urlopen_Mock() 159 | user_agent = 'user-agent' 160 | # http://www.ietf.org/rfc/rfc2616.txt 161 | # OktaOpenVPN/1.0.0 (Darwin 13.4.0) CPython/2.7.1 162 | okta.preauth() 163 | args = okta.pool.urlopen.call_args_list 164 | headers = args[0][1]['headers'] 165 | self.assertIn(user_agent, headers) 166 | actual = headers[user_agent] 167 | import platform 168 | system = platform.uname()[0] 169 | system_version = platform.uname()[2] 170 | python_version = "{}/{}".format( 171 | platform.python_implementation(), 172 | platform.python_version(), 173 | ) 174 | for expected in ['OktaOpenVPN/', 175 | system, system_version, python_version]: 176 | self.assertIn(expected, actual) 177 | 178 | # test_invalid_okta_api_token 179 | # "authentication filed for unknown reason" 180 | 181 | # test_other_auth_failure_reason (locked, etc) 182 | # "not allowed to authenticate" 183 | -------------------------------------------------------------------------------- /CODE.md: -------------------------------------------------------------------------------- 1 | - [An overview of how okta-openvpn works](#org835b848) 2 | - [Instantiate an OktaOpenVPNValidator object](#org3ac730b) 3 | - [Load in configuration file and environment variables](#org5d7701b) 4 | - [Authenticate the user](#org3312102) 5 | - [Write result to the control file](#orgab0ed37) 6 | - [Learn more](#org9c0edcb) 7 | 8 | 9 | 10 | 11 | # An overview of how okta-openvpn works 12 | 13 | This is a plugin for OpenVPN Community Edition that allows OpenVPN to authenticate directly against Okta, with support for TOTP and Okta Verify Push factors. 14 | 15 | At a high level, OpenVPN communicates with this plugin via a "control file", a temporary file that OpenVPN creates and polls periodicly. If the plugin writes the ASCII character "1" into the control file, the user in question is allowed to log in to OpenVPN, if we write the ASCII character "0" into the file, the user is denied. 16 | 17 | Below are the key parts of the code for `okta_openvpn.py`: 18 | 19 | 1. Instantiate an OktaOpenVPNValidator object 20 | 2. Load in configuration file and environment variables 21 | 3. Authenticate the user 22 | 4. Write result to the control file 23 | 24 | 25 | 26 | 27 | ## Instantiate an OktaOpenVPNValidator object 28 | 29 | The code flow for authenticating a user is as follows: 30 | 31 | Here is how we instantiate an OktaOpenVPNValidator object: 32 | 33 | ```python 34 | # This is tested by test_command.sh via tests/test_command.py 35 | if __name__ == "__main__": # pragma: no cover 36 | validator = OktaOpenVPNValidator() 37 | validator.run() 38 | return_error_code_for(validator) 39 | ``` 40 | 41 | 42 | 43 | 44 | ## Load in configuration file and environment variables 45 | 46 | Here is the `run()` method of the OktaOpenVPNValidator class, this is what calls the methods which load the configuration file and environment variables, then calls the `authenticate()` method. 47 | 48 | ```python 49 | def run(self): 50 | self.read_configuration_file() 51 | self.load_environment_variables() 52 | self.authenticate() 53 | self.write_result_to_control_file() 54 | ``` 55 | 56 | 57 | 58 | 59 | ## Authenticate the user 60 | 61 | Here is the `authenticate()` method: 62 | 63 | ```python 64 | def authenticate(self): 65 | if not self.username_trusted: 66 | log.warning("Username %s is not trusted - failing", 67 | self.okta_config['username']) 68 | return False 69 | try: 70 | okta = self.cls(**self.okta_config) 71 | self.user_valid = okta.auth() 72 | return self.user_valid 73 | except Exception as exception: 74 | log.error( 75 | "User %s (%s) authentication failed, " 76 | "because %s() failed unexpectedly - %s", 77 | self.okta_config['username'], 78 | self.okta_config['client_ipaddr'], 79 | self.cls.__name__, 80 | exception 81 | ) 82 | return False 83 | ``` 84 | 85 | This code in turns calls the `auth()` method in the `OktaAPIAuth` class, which does the following: 86 | 87 | - Makes an authentication request to Okta, using the `preauth()` method. 88 | - Checks for errors 89 | - Log the user in if the reply was `SUCCESS` 90 | - Deny the user if the reply is `MFA_ENROLL` or `MFA_ENROLL_ACTIVATE` 91 | 92 | If the response is `MFA_REQUIRED` or `MFA_CHALLENGE` then we do the following, for each factor that the user has registered: 93 | 94 | - Skip the factor if this code doesn't support that factor type. 95 | - Call `doauth()`, the second phase authentication, using the passcode (if we have one) and the `stateToken`. 96 | - Keep running `doauth()` if the response type is `MFA_CHALLENGE` or `WAITING`. 97 | - If there response from `doauth()` is `SUCCESS` then log the user in. 98 | - Fail otherwise. 99 | 100 | When returning errors, we prefer the summary strings in `errorCauses`, over those in `errorSummary` because the strings in `errorCauses` tend to be mroe descriptive. For more information, see the documentation for [Verify Security Question Factor](http://developer.okta.com/docs/api/resources/authn.html#verify-security-question-factor). 101 | 102 | ```python 103 | try: 104 | rv = self.preauth() 105 | except Exception as s: 106 | log.error('Error connecting to the Okta API: %s', s) 107 | return False 108 | # Check for erros from Okta 109 | if 'errorCauses' in rv: 110 | msg = rv['errorSummary'] 111 | log.info('User %s pre-authentication failed: %s', 112 | self.username, 113 | msg) 114 | return False 115 | elif 'status' in rv: 116 | status = rv['status'] 117 | # Check authentication status from Okta 118 | if status == "SUCCESS": 119 | log.info('User %s authenticated without MFA', self.username) 120 | return True 121 | elif status == "MFA_ENROLL" or status == "MFA_ENROLL_ACTIVATE": 122 | log.info('User %s needs to enroll first', self.username) 123 | return False 124 | elif status == "MFA_REQUIRED" or status == "MFA_CHALLENGE": 125 | log.debug("User %s password validates, checking second factor", 126 | self.username) 127 | res = None 128 | for factor in rv['_embedded']['factors']: 129 | supported_factor_types = ["token:software:totp", "push"] 130 | if factor['factorType'] not in supported_factor_types: 131 | continue 132 | fid = factor['id'] 133 | state_token = rv['stateToken'] 134 | try: 135 | res = self.doauth(fid, state_token) 136 | check_count = 0 137 | fctr_rslt = 'factorResult' 138 | while fctr_rslt in res and res[fctr_rslt] == 'WAITING': 139 | print("Sleeping for {}".format( 140 | self.mfa_push_delay_secs)) 141 | time.sleep(self.mfa_push_delay_secs) 142 | res = self.doauth(fid, state_token) 143 | check_count += 1 144 | if check_count > self.mfa_push_max_retries: 145 | log.info('User %s MFA push timed out' % 146 | self.username) 147 | return False 148 | except Exception as e: 149 | log.error('Unexpected error with the Okta API: %s', e) 150 | return False 151 | if 'status' in res and res['status'] == 'SUCCESS': 152 | log.info("User %s is now authenticated " 153 | "with MFA via Okta API", self.username) 154 | return True 155 | if 'errorCauses' in res: 156 | msg = res['errorCauses'][0]['errorSummary'] 157 | log.debug('User %s MFA token authentication failed: %s', 158 | self.username, 159 | msg) 160 | return False 161 | else: 162 | log.info("User %s is not allowed to authenticate: %s", 163 | self.username, 164 | status) 165 | return False 166 | ``` 167 | 168 | 169 | 170 | 171 | ## Write result to the control file 172 | 173 | **Important:** The key thing to know about OpenVPN plugins (like this one) are that they communicate with OpenVPN through a **control file**. When OpenVPN calls a plugin, it first creates a temporary file, passes the name of the temporary file to the plugin, then waits for the temporary file to be written. 174 | 175 | If a "**1**" is written to the file, OpenVPN logs the user in. If a "**0**" is written to the file, the user is denied. 176 | 177 | Here is what the code does below: 178 | 179 | Because of how critical this control file is, we take the precaution of checking the permissions on the control file before writing anything to the file. 180 | 181 | If the user authentication that happened previously was a success, we write a **1** to the file. Otherwise, we write a **0** to the file, denying the user by default. 182 | 183 | ```python 184 | def write_result_to_control_file(self): 185 | self.check_control_file_permissions() 186 | try: 187 | with open(self.control_file, 'w') as f: 188 | if self.user_valid: 189 | f.write('1') 190 | else: 191 | f.write('0') 192 | except IOError: 193 | log.critical("Failed to write to OpenVPN control file '{}'".format( 194 | self.control_file 195 | )) 196 | ``` 197 | 198 | 199 | 200 | 201 | # Learn more 202 | 203 | Read the source on GitHub: 204 | 205 | Key files to read: 206 | 207 | - 208 | - 209 | -------------------------------------------------------------------------------- /tests/test_OktaOpenVPNValidator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | from mock import MagicMock 5 | from mock import patch 6 | 7 | from okta_openvpn import OktaOpenVPNValidator 8 | from tests.shared import MockEnviron 9 | from tests.shared import OktaTestCase 10 | from tests.shared import ThrowsErrorOktaAPI 11 | import okta_openvpn 12 | 13 | 14 | class TestOktaAPIAuth(OktaTestCase): 15 | def setUp(self): 16 | super(TestOktaAPIAuth, self).setUp() 17 | self.config['assert_pinset'] = [self.herokuapp_dot_com_pin] 18 | 19 | def test_invalid_configuration_file(self): 20 | validator = OktaOpenVPNValidator() 21 | validator.config_file = '/dev/false' 22 | rv = validator.read_configuration_file() 23 | self.assertEquals(rv, False) 24 | last_error = self.okta_log_messages['critical'][-1:][0] 25 | self.assertIn('Failed to load config', last_error) 26 | 27 | def test_no_okta_url(self): 28 | env = MockEnviron({}) 29 | validator = OktaOpenVPNValidator() 30 | validator.env = env 31 | rv = validator.load_environment_variables() 32 | self.assertEquals(rv, False) 33 | last_error = self.okta_log_messages['critical'][-1:][0] 34 | self.assertIn('OKTA_URL not defined', last_error) 35 | 36 | def test_okta_url_no_token(self): 37 | cfg = { 38 | 'okta_url': self.okta_url 39 | } 40 | # Empty out the Mock Environment 41 | env = MockEnviron({}) 42 | validator = OktaOpenVPNValidator() 43 | validator.site_config = cfg 44 | validator.env = env 45 | rv = validator.load_environment_variables() 46 | self.assertEquals(rv, False) 47 | last_error = self.okta_log_messages['critical'][-1:][0] 48 | self.assertIn('OKTA_TOKEN not defined', last_error) 49 | 50 | def test_no_username_or_password(self): 51 | # Make our own config with out username or password 52 | cfg = { 53 | 'okta_url': self.okta_url, 54 | 'okta_token': self.okta_token, 55 | } 56 | env = MockEnviron({}) 57 | validator = OktaOpenVPNValidator() 58 | validator.site_config = cfg 59 | validator.env = env 60 | rv = validator.load_environment_variables() 61 | rv = validator.authenticate() 62 | self.assertEquals(rv, False) 63 | last_error = self.okta_log_messages['warning'][-1:][0] 64 | self.assertIn('is not trusted - failing', last_error) 65 | 66 | def test_okta_verify_push(self): 67 | cfg = { 68 | 'okta_url': self.okta_url, 69 | 'okta_token': self.okta_token, 70 | 'mfa_push_max_retries': 20, 71 | 'mfa_push_delay_secs': self.mfa_push_delay_secs, 72 | } 73 | env = MockEnviron({ 74 | 'common_name': 'user_MFA_PUSH@example.com', 75 | 'password': self.config['password'] 76 | }) 77 | validator = OktaOpenVPNValidator() 78 | validator.site_config = cfg 79 | validator.env = env 80 | validator.load_environment_variables() 81 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 82 | 83 | rv = validator.authenticate() 84 | self.assertEquals(rv, True) 85 | last_error = self.okta_log_messages['info'][-1] 86 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 87 | 88 | @patch('time.sleep', return_value=None) 89 | def test_okta_verify_push_int(self, patched_time_sleep): 90 | cfg = { 91 | 'okta_url': self.okta_url, 92 | 'okta_token': self.okta_token, 93 | 'mfa_push_max_retries': int(20), 94 | 'mfa_push_delay_secs': int(11), 95 | } 96 | env = MockEnviron({ 97 | 'common_name': 'user_MFA_PUSH@example.com', 98 | 'password': self.config['password'] 99 | }) 100 | validator = OktaOpenVPNValidator() 101 | validator.site_config = cfg 102 | validator.env = env 103 | validator.load_environment_variables() 104 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 105 | validator.authenticate() 106 | for call in patched_time_sleep.call_args_list: 107 | args, kwargs = call 108 | for arg in args: 109 | import pprint 110 | pprint.pprint(arg) 111 | msg = "time.sleep() must be called with a float not %s" 112 | assert isinstance(arg, float), msg % type(arg) 113 | patched_time_sleep.assert_called_with(11) 114 | 115 | def test_okta_verify_push_timeout(self): 116 | cfg = { 117 | 'okta_url': self.okta_url, 118 | 'okta_token': self.okta_token, 119 | 'mfa_push_max_retries': 1, 120 | 'mfa_push_delay_secs': self.mfa_push_delay_secs, 121 | } 122 | env = MockEnviron({ 123 | 'common_name': 'user_MFA_PUSH@example.com', 124 | 'password': self.config['password'] 125 | }) 126 | validator = OktaOpenVPNValidator() 127 | validator.site_config = cfg 128 | validator.env = env 129 | validator.load_environment_variables() 130 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 131 | 132 | rv = validator.authenticate() 133 | self.assertEquals(rv, False) 134 | last_error = self.okta_log_messages['info'][-1] 135 | self.assertIn('push timed out', last_error) 136 | 137 | def test_okta_verify_push_fails(self): 138 | cfg = { 139 | 'okta_url': self.okta_url, 140 | 'okta_token': self.okta_token, 141 | 'mfa_push_max_retries': 20, 142 | 'mfa_push_delay_secs': self.mfa_push_delay_secs, 143 | } 144 | env = MockEnviron({ 145 | 'common_name': 'user_MFA_PUSH_REJECTED@example.com', 146 | 'password': self.config['password'] 147 | }) 148 | validator = OktaOpenVPNValidator() 149 | validator.site_config = cfg 150 | validator.env = env 151 | validator.load_environment_variables() 152 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 153 | 154 | rv = validator.authenticate() 155 | self.assertEquals(rv, False) 156 | 157 | def test_with_username_and_password(self): 158 | cfg = { 159 | 'okta_url': self.okta_url, 160 | 'okta_token': self.okta_token, 161 | } 162 | env = MockEnviron({ 163 | 'common_name': self.config['username'], 164 | 'password': self.config['password'] 165 | }) 166 | validator = OktaOpenVPNValidator() 167 | validator.site_config = cfg 168 | validator.env = env 169 | validator.load_environment_variables() 170 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 171 | 172 | rv = validator.authenticate() 173 | self.assertEquals(rv, True) 174 | last_error = self.okta_log_messages['info'][-1:][0] 175 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 176 | 177 | def test_with_valid_config_file(self): 178 | config_format = ( 179 | "[OktaAPI]\n" 180 | "Url: {}\n" 181 | "Token: {}\n") 182 | cfg = tempfile.NamedTemporaryFile() 183 | cfg.file.write(config_format.format( 184 | self.okta_url, 185 | self.okta_token)) 186 | cfg.file.seek(0) 187 | env = MockEnviron({ 188 | 'common_name': self.config['username'], 189 | 'password': self.config['password'] 190 | }) 191 | validator = OktaOpenVPNValidator() 192 | validator.config_file = cfg.name 193 | validator.env = env 194 | validator.read_configuration_file() 195 | validator.load_environment_variables() 196 | # Disable Public Key Pinning 197 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 198 | rv = validator.authenticate() 199 | self.assertEquals(rv, True) 200 | last_error = self.okta_log_messages['info'][-1:][0] 201 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 202 | 203 | def test_with_valid_config_file_with_untrusted_user_enabled(self): 204 | config_format = ( 205 | "[OktaAPI]\n" 206 | "Url: {}\n" 207 | "Token: {}\n" 208 | "AllowUntrustedUsers: True") 209 | cfg = tempfile.NamedTemporaryFile() 210 | cfg.file.write(config_format.format( 211 | self.okta_url, 212 | self.okta_token)) 213 | cfg.file.seek(0) 214 | env = MockEnviron({ 215 | 'username': self.config['username'], 216 | 'password': self.config['password'] 217 | }) 218 | validator = OktaOpenVPNValidator() 219 | validator.config_file = cfg.name 220 | validator.env = env 221 | validator.read_configuration_file() 222 | validator.load_environment_variables() 223 | # Disable Public Key Pinning 224 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 225 | rv = validator.authenticate() 226 | self.assertEquals(rv, True) 227 | last_error = self.okta_log_messages['info'][-1:][0] 228 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 229 | 230 | def test_with_valid_config_file_with_untrusted_user_disabled(self): 231 | for val in ['yes', '1', 'true', 'ok', 'False', '0']: 232 | config_format = ( 233 | "[OktaAPI]\n" 234 | "Url: {}\n" 235 | "Token: {}\n" 236 | "AllowUntrustedUsers: {}") 237 | cfg = tempfile.NamedTemporaryFile() 238 | cfg.file.write(config_format.format( 239 | self.okta_url, 240 | self.okta_token, 241 | val)) 242 | cfg.file.seek(0) 243 | env = MockEnviron({ 244 | 'username': self.config['username'], 245 | 'password': self.config['password'] 246 | }) 247 | validator = OktaOpenVPNValidator() 248 | validator.config_file = cfg.name 249 | validator.env = env 250 | validator.read_configuration_file() 251 | validator.load_environment_variables() 252 | # Disable Public Key Pinning 253 | validator.okta_config['assert_pinset'] = [ 254 | self.herokuapp_dot_com_pin] 255 | rv = validator.authenticate() 256 | self.assertEquals(rv, False) 257 | 258 | def test_suffix_with_username_and_password(self): 259 | cfg = { 260 | 'okta_url': self.okta_url, 261 | 'okta_token': self.okta_token, 262 | } 263 | env = MockEnviron({ 264 | 'common_name': self.username_prefix, 265 | 'password': self.config['password'] 266 | }) 267 | validator = OktaOpenVPNValidator() 268 | validator.site_config = cfg 269 | validator.username_suffix = self.username_suffix 270 | validator.env = env 271 | validator.load_environment_variables() 272 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 273 | 274 | rv = validator.authenticate() 275 | self.assertEquals(rv, True) 276 | last_error = self.okta_log_messages['info'][-1:][0] 277 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 278 | 279 | def test_suffix_where_username_contains_suffix_already(self): 280 | cfg = { 281 | 'okta_url': self.okta_url, 282 | 'okta_token': self.okta_token, 283 | } 284 | env = MockEnviron({ 285 | 'common_name': self.config['username'], 286 | 'password': self.config['password'] 287 | }) 288 | validator = OktaOpenVPNValidator() 289 | validator.site_config = cfg 290 | validator.username_suffix = self.username_suffix 291 | validator.env = env 292 | validator.load_environment_variables() 293 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 294 | 295 | rv = validator.authenticate() 296 | self.assertEquals(rv, True) 297 | last_error = self.okta_log_messages['info'][-1:][0] 298 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 299 | 300 | def test_suffix_with_valid_config_file(self): 301 | config_format = ( 302 | "[OktaAPI]\n" 303 | "Url: {}\n" 304 | "Token: {}\n" 305 | "UsernameSuffix: {}\n") 306 | cfg = tempfile.NamedTemporaryFile() 307 | cfg.file.write(config_format.format( 308 | self.okta_url, 309 | self.okta_token, 310 | self.username_suffix)) 311 | cfg.file.seek(0) 312 | env = MockEnviron({ 313 | 'common_name': self.username_prefix, 314 | 'password': self.config['password'] 315 | }) 316 | validator = OktaOpenVPNValidator() 317 | validator.config_file = cfg.name 318 | validator.env = env 319 | validator.read_configuration_file() 320 | validator.load_environment_variables() 321 | # Disable Public Key Pinning 322 | validator.okta_config['assert_pinset'] = [self.herokuapp_dot_com_pin] 323 | rv = validator.authenticate() 324 | self.assertEquals(rv, True) 325 | last_error = self.okta_log_messages['info'][-1:][0] 326 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 327 | 328 | def test_with_invalid_config_file(self): 329 | cfg = tempfile.NamedTemporaryFile() 330 | cfg.file.write('invalidconfig') 331 | cfg.file.seek(0) 332 | env = MockEnviron({ 333 | 'common_name': self.config['username'], 334 | 'password': self.config['password'] 335 | }) 336 | validator = OktaOpenVPNValidator() 337 | validator.config_file = cfg.name 338 | validator.env = env 339 | rv = validator.read_configuration_file() 340 | self.assertEquals(rv, False) 341 | 342 | def test_return_error_code_true(self): 343 | validator = OktaOpenVPNValidator() 344 | validator.user_valid = True 345 | okta_openvpn.sys = MagicMock() 346 | okta_openvpn.return_error_code_for(validator) 347 | okta_openvpn.sys.exit.assert_called_with(0) 348 | 349 | def test_return_error_code_false(self): 350 | validator = OktaOpenVPNValidator() 351 | validator.user_valid = False 352 | okta_openvpn.sys = MagicMock() 353 | okta_openvpn.return_error_code_for(validator) 354 | okta_openvpn.sys.exit.assert_called_with(1) 355 | 356 | def test_authenticate_handles_exceptions(self): 357 | cfg = { 358 | 'okta_url': self.okta_url, 359 | 'okta_token': self.okta_token, 360 | } 361 | env = MockEnviron({ 362 | 'common_name': self.config['username'], 363 | 'password': self.config['password'] 364 | }) 365 | validator = OktaOpenVPNValidator() 366 | validator.cls = ThrowsErrorOktaAPI 367 | validator.site_config = cfg 368 | validator.env = env 369 | validator.load_environment_variables() 370 | rv = validator.authenticate() 371 | self.assertEquals(rv, False) 372 | last_error = self.okta_log_messages['error'][-1:][0] 373 | self.assertIn('authentication failed, because', last_error) 374 | 375 | def test_write_0_to_control_file(self): 376 | tmp = tempfile.NamedTemporaryFile() 377 | validator = OktaOpenVPNValidator() 378 | validator.control_file = tmp.name 379 | validator.write_result_to_control_file() 380 | tmp.file.seek(0) 381 | rv = tmp.file.read() 382 | self.assertEquals(rv, '0') 383 | 384 | def test_write_1_to_control_file(self): 385 | tmp = tempfile.NamedTemporaryFile() 386 | validator = OktaOpenVPNValidator() 387 | validator.user_valid = True 388 | validator.control_file = tmp.name 389 | validator.write_result_to_control_file() 390 | tmp.file.seek(0) 391 | rv = tmp.file.read() 392 | self.assertEquals(rv, '1') 393 | 394 | def test_write_ro_to_control_file(self): 395 | tmp = tempfile.NamedTemporaryFile() 396 | os.chmod(tmp.name, 0400) 397 | validator = OktaOpenVPNValidator() 398 | validator.user_valid = True 399 | validator.control_file = tmp.name 400 | validator.write_result_to_control_file() 401 | tmp.file.seek(0) 402 | rv = tmp.file.read() 403 | self.assertEquals(rv, '') 404 | 405 | tmp.file.seek(0) 406 | validator.user_valid = False 407 | validator.write_result_to_control_file() 408 | tmp.file.seek(0) 409 | rv = tmp.file.read() 410 | self.assertEquals(rv, '') 411 | 412 | def test_OktaOpenVPNValidator_run(self): 413 | cfg = { 414 | 'okta_url': self.okta_url, 415 | 'okta_token': self.okta_token, 416 | } 417 | tmp = tempfile.NamedTemporaryFile() 418 | env = MockEnviron({ 419 | 'common_name': self.config['username'], 420 | 'password': self.config['password'], 421 | 'auth_control_file': tmp.name, 422 | 'assert_pin': self.herokuapp_dot_com_pin, 423 | }) 424 | 425 | validator = OktaOpenVPNValidator() 426 | validator.site_config = cfg 427 | validator.env = env 428 | 429 | validator.run() 430 | 431 | self.assertTrue(validator.user_valid) 432 | tmp.file.seek(0) 433 | rv = tmp.file.read() 434 | self.assertEquals(rv, '1') 435 | last_error = self.okta_log_messages['info'][-1:][0] 436 | self.assertIn('is now authenticated with MFA via Okta API', last_error) 437 | 438 | # def test_control_file_world_writeable 439 | # def test_control_file_ro_gives_error 440 | # def test_tmp_dir_bad_permissions 441 | -------------------------------------------------------------------------------- /okta_openvpn.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # vim: set noexpandtab:ts=4 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | # Contributors: gdestuynder@mozilla.com 8 | 9 | import ConfigParser 10 | from ConfigParser import MissingSectionHeaderError 11 | import base64 12 | import hashlib 13 | import json 14 | import logging 15 | import logging.handlers 16 | import os 17 | import platform 18 | import stat 19 | import sys 20 | import time 21 | import urlparse 22 | 23 | from cryptography import x509 24 | from cryptography.hazmat.backends import default_backend 25 | from cryptography.hazmat.primitives import serialization 26 | import certifi 27 | import urllib3 28 | 29 | from okta_pinset import okta_pinset 30 | 31 | version = "0.10.2-beta" 32 | # OktaOpenVPN/0.10.0 (Darwin 12.4.0) CPython/2.7.5 33 | user_agent = ("OktaOpenVPN/{version} " 34 | "({system} {system_version}) " 35 | "{implementation}/{python_version}").format( 36 | version=version, 37 | system=platform.uname()[0], 38 | system_version=platform.uname()[2], 39 | implementation=platform.python_implementation(), 40 | python_version=platform.python_version()) 41 | log = logging.getLogger('okta_openvpn') 42 | log.setLevel(logging.DEBUG) 43 | syslog = logging.handlers.SysLogHandler() 44 | syslog_fmt = "%(module)s-%(processName)s[%(process)d]: %(name)s: %(message)s" 45 | syslog.setFormatter(logging.Formatter(syslog_fmt)) 46 | log.addHandler(syslog) 47 | # # Uncomment to enable logging to STDERR 48 | # errlog = logging.StreamHandler() 49 | # errlog.setFormatter(logging.Formatter(syslog_fmt)) 50 | # log.addHandler(errlog) 51 | # # Uncomment to enable logging to a file 52 | # filelog = logging.FileHandler('/tmp/okta_openvpn.log') 53 | # filelog.setFormatter(logging.Formatter(syslog_fmt)) 54 | # log.addHandler(filelog) 55 | 56 | 57 | class PinError(Exception): 58 | "Raised when a pin isn't found in a certificate" 59 | pass 60 | 61 | 62 | class ControlFilePermissionsError(Exception): 63 | "Raised when the control file or containing directory have bad permissions" 64 | pass 65 | 66 | 67 | class PublicKeyPinsetConnectionPool(urllib3.HTTPSConnectionPool): 68 | def __init__(self, *args, **kwargs): 69 | self.pinset = kwargs.pop('assert_pinset', None) 70 | super(PublicKeyPinsetConnectionPool, self).__init__(*args, **kwargs) 71 | 72 | def _validate_conn(self, conn): 73 | super(PublicKeyPinsetConnectionPool, self)._validate_conn(conn) 74 | if not conn.is_verified: 75 | raise Exception("Unexpected verification error.") 76 | 77 | cert = conn.sock.getpeercert(binary_form=True) 78 | public_key = x509.load_der_x509_certificate( 79 | cert, 80 | default_backend()).public_key() 81 | public_key_raw = public_key.public_bytes( 82 | serialization.Encoding.DER, 83 | serialization.PublicFormat.SubjectPublicKeyInfo) 84 | public_key_sha256 = hashlib.sha256(public_key_raw).digest() 85 | public_key_sha256_base64 = base64.b64encode(public_key_sha256) 86 | 87 | if public_key_sha256_base64 not in self.pinset: 88 | pin_failure_message = ( 89 | 'Refusing to authenticate ' 90 | 'because host {remote_host} failed ' 91 | 'a TLS public key pinning check. ' 92 | 'Please contact support@okta.com with this error message' 93 | ).format(remote_host=conn.host) 94 | log.critical(pin_failure_message) 95 | raise PinError("Public Key not found in pinset!") 96 | 97 | 98 | class OktaAPIAuth(object): 99 | def __init__(self, okta_url, okta_token, 100 | username, password, client_ipaddr, 101 | mfa_push_delay_secs=None, 102 | mfa_push_max_retries=None, 103 | assert_pinset=None): 104 | passcode_len = 6 105 | self.okta_url = None 106 | self.okta_token = okta_token 107 | self.username = username 108 | self.password = password 109 | self.client_ipaddr = client_ipaddr 110 | self.passcode = None 111 | self.okta_urlparse = urlparse.urlparse(okta_url) 112 | self.mfa_push_delay_secs = mfa_push_delay_secs 113 | self.mfa_push_max_retries = mfa_push_max_retries 114 | if assert_pinset is None: 115 | assert_pinset = okta_pinset 116 | url_new = (self.okta_urlparse.scheme, 117 | self.okta_urlparse.netloc, 118 | '', '', '', '') 119 | self.okta_url = urlparse.urlunparse(url_new) 120 | if password and len(password) > passcode_len: 121 | last = password[-passcode_len:] 122 | if last.isdigit(): 123 | self.passcode = last 124 | self.password = password[:-passcode_len] 125 | self.pool = PublicKeyPinsetConnectionPool( 126 | self.okta_urlparse.hostname, 127 | self.okta_urlparse.port, 128 | assert_pinset=assert_pinset, 129 | cert_reqs='CERT_REQUIRED', 130 | ca_certs=certifi.where(), 131 | ) 132 | 133 | def okta_req(self, path, data): 134 | ssws = "SSWS {token}".format(token=self.okta_token) 135 | headers = { 136 | 'user-agent': user_agent, 137 | 'content-type': 'application/json', 138 | 'accept': 'application/json', 139 | 'authorization': ssws, 140 | } 141 | url = "{base}/api/v1{path}".format(base=self.okta_url, path=path) 142 | req = self.pool.urlopen( 143 | 'POST', 144 | url, 145 | headers=headers, 146 | body=json.dumps(data) 147 | ) 148 | return json.loads(req.data) 149 | 150 | def preauth(self): 151 | path = "/authn" 152 | data = { 153 | 'username': self.username, 154 | 'password': self.password, 155 | } 156 | return self.okta_req(path, data) 157 | 158 | def doauth(self, fid, state_token): 159 | path = "/authn/factors/{fid}/verify".format(fid=fid) 160 | data = { 161 | 'fid': fid, 162 | 'stateToken': state_token, 163 | 'passCode': self.passcode, 164 | } 165 | return self.okta_req(path, data) 166 | 167 | def auth(self): 168 | username = self.username 169 | password = self.password 170 | status = False 171 | rv = False 172 | 173 | invalid_username_or_password = ( 174 | username is None or 175 | username == '' or 176 | password is None or 177 | password == '') 178 | if invalid_username_or_password: 179 | log.info("Missing username or password for user: %s (%s) - " 180 | "Reported username may be 'None' due to this", 181 | username, 182 | self.client_ipaddr) 183 | return False 184 | 185 | if not self.passcode: 186 | log.info("No second factor found for username %s", username) 187 | 188 | log.debug("Authenticating username %s", username) 189 | try: 190 | rv = self.preauth() 191 | except Exception as s: 192 | log.error('Error connecting to the Okta API: %s', s) 193 | return False 194 | # Check for erros from Okta 195 | if 'errorCauses' in rv: 196 | msg = rv['errorSummary'] 197 | log.info('User %s pre-authentication failed: %s', 198 | self.username, 199 | msg) 200 | return False 201 | elif 'status' in rv: 202 | status = rv['status'] 203 | # Check authentication status from Okta 204 | if status == "SUCCESS": 205 | log.info('User %s authenticated without MFA', self.username) 206 | return True 207 | elif status == "MFA_ENROLL" or status == "MFA_ENROLL_ACTIVATE": 208 | log.info('User %s needs to enroll first', self.username) 209 | return False 210 | elif status == "MFA_REQUIRED" or status == "MFA_CHALLENGE": 211 | log.debug("User %s password validates, checking second factor", 212 | self.username) 213 | res = None 214 | for factor in rv['_embedded']['factors']: 215 | supported_factor_types = ["token:software:totp", "push"] 216 | if factor['factorType'] not in supported_factor_types: 217 | continue 218 | fid = factor['id'] 219 | state_token = rv['stateToken'] 220 | try: 221 | res = self.doauth(fid, state_token) 222 | check_count = 0 223 | fctr_rslt = 'factorResult' 224 | while fctr_rslt in res and res[fctr_rslt] == 'WAITING': 225 | print("Sleeping for {}".format( 226 | self.mfa_push_delay_secs)) 227 | time.sleep(float(self.mfa_push_delay_secs)) 228 | res = self.doauth(fid, state_token) 229 | check_count += 1 230 | if check_count > self.mfa_push_max_retries: 231 | log.info('User %s MFA push timed out' % 232 | self.username) 233 | return False 234 | except Exception as e: 235 | log.error('Unexpected error with the Okta API: %s', e) 236 | return False 237 | if 'status' in res and res['status'] == 'SUCCESS': 238 | log.info("User %s is now authenticated " 239 | "with MFA via Okta API", self.username) 240 | return True 241 | if 'errorCauses' in res: 242 | msg = res['errorCauses'][0]['errorSummary'] 243 | log.debug('User %s MFA token authentication failed: %s', 244 | self.username, 245 | msg) 246 | return False 247 | else: 248 | log.info("User %s is not allowed to authenticate: %s", 249 | self.username, 250 | status) 251 | return False 252 | 253 | 254 | class OktaOpenVPNValidator(object): 255 | def __init__(self): 256 | self.cls = OktaAPIAuth 257 | self.username_trusted = False 258 | self.user_valid = False 259 | self.control_file = None 260 | self.site_config = {} 261 | self.config_file = None 262 | self.env = os.environ 263 | self.okta_config = {} 264 | self.username_suffix = None 265 | self.always_trust_username = False 266 | # These can be modified in the 'okta_openvpn.ini' file. 267 | # By default, we retry for 2 minutes: 268 | self.mfa_push_max_retries = "20" 269 | self.mfa_push_delay_secs = "3" 270 | 271 | def read_configuration_file(self): 272 | cfg_path_defaults = [ 273 | '/etc/openvpn/okta_openvpn.ini', 274 | '/etc/okta_openvpn.ini', 275 | 'okta_openvpn.ini'] 276 | cfg_path = cfg_path_defaults 277 | parser_defaults = { 278 | 'AllowUntrustedUsers': self.always_trust_username, 279 | 'UsernameSuffix': self.username_suffix, 280 | 'MFAPushMaxRetries': self.mfa_push_max_retries, 281 | 'MFAPushDelaySeconds': self.mfa_push_delay_secs, 282 | } 283 | if self.config_file: 284 | cfg_path = [] 285 | cfg_path.append(self.config_file) 286 | log.debug(cfg_path) 287 | for cfg_file in cfg_path: 288 | if os.path.isfile(cfg_file): 289 | try: 290 | cfg = ConfigParser.ConfigParser(defaults=parser_defaults) 291 | cfg.read(cfg_file) 292 | self.site_config = { 293 | 'okta_url': cfg.get('OktaAPI', 'Url'), 294 | 'okta_token': cfg.get('OktaAPI', 'Token'), 295 | 'mfa_push_max_retries': cfg.get('OktaAPI', 296 | 'MFAPushMaxRetries'), 297 | 'mfa_push_delay_secs': cfg.get('OktaAPI', 298 | 'MFAPushDelaySeconds'), 299 | } 300 | always_trust_username = cfg.get( 301 | 'OktaAPI', 302 | 'AllowUntrustedUsers') 303 | if always_trust_username == 'True': 304 | self.always_trust_username = True 305 | self.username_suffix = cfg.get('OktaAPI', 'UsernameSuffix') 306 | return True 307 | except MissingSectionHeaderError as e: 308 | log.debug(e) 309 | if 'okta_url' not in self.site_config and \ 310 | 'okta_token' not in self.site_config: 311 | log.critical("Failed to load config") 312 | return False 313 | 314 | def load_environment_variables(self): 315 | if 'okta_url' not in self.site_config: 316 | log.critical('OKTA_URL not defined in configuration') 317 | return False 318 | if 'okta_token' not in self.site_config: 319 | log.critical('OKTA_TOKEN not defined in configuration') 320 | return False 321 | # Taken from a validated VPN client-side SSL certificate 322 | username = self.env.get('common_name') 323 | password = self.env.get('password') 324 | client_ipaddr = self.env.get('untrusted_ip', '0.0.0.0') 325 | # Note: 326 | # username_trusted is True if the username comes from a certificate 327 | # 328 | # Meaning, if self.common_name is NOT set, but self.username IS, 329 | # then self.username_trusted will be False 330 | if username is not None: 331 | self.username_trusted = True 332 | else: 333 | # This is set according to what the VPN client has sent us 334 | username = self.env.get('username') 335 | if self.always_trust_username: 336 | self.username_trusted = self.always_trust_username 337 | if self.username_suffix and '@' not in username: 338 | username = username + '@' + self.username_suffix 339 | self.control_file = self.env.get('auth_control_file') 340 | if self.control_file is None: 341 | log.info(("No control file found, " 342 | "if using a deferred plugin " 343 | "authentication will stall and fail.")) 344 | self.okta_config = { 345 | 'okta_url': self.site_config['okta_url'], 346 | 'okta_token': self.site_config['okta_token'], 347 | 'username': username, 348 | 'password': password, 349 | 'client_ipaddr': client_ipaddr, 350 | } 351 | for item in ['mfa_push_max_retries', 'mfa_push_delay_secs']: 352 | if item in self.site_config: 353 | self.okta_config[item] = self.site_config[item] 354 | assert_pin = self.env.get('assert_pin') 355 | if assert_pin: 356 | self.okta_config['assert_pinset'] = [assert_pin] 357 | 358 | def authenticate(self): 359 | if not self.username_trusted: 360 | log.warning("Username %s is not trusted - failing", 361 | self.okta_config['username']) 362 | return False 363 | try: 364 | okta = self.cls(**self.okta_config) 365 | self.user_valid = okta.auth() 366 | return self.user_valid 367 | except Exception as exception: 368 | log.error( 369 | "User %s (%s) authentication failed, " 370 | "because %s() failed unexpectedly - %s", 371 | self.okta_config['username'], 372 | self.okta_config['client_ipaddr'], 373 | self.cls.__name__, 374 | exception 375 | ) 376 | return False 377 | 378 | def check_control_file_permissions(self): 379 | file_mode = os.stat(self.control_file).st_mode 380 | if file_mode & stat.S_IWGRP or file_mode & stat.S_IWOTH: 381 | log.critical( 382 | 'Refusing to authenticate. The file %s' 383 | ' must not be writable by non-owners.', 384 | self.control_file 385 | ) 386 | raise ControlFilePermissionsError() 387 | dir_name = os.path.split(self.control_file)[0] 388 | dir_mode = os.stat(dir_name).st_mode 389 | if dir_mode & stat.S_IWGRP or dir_mode & stat.S_IWOTH: 390 | log.critical( 391 | 'Refusing to authenticate.' 392 | ' The directory containing the file %s' 393 | ' must not be writable by non-owners.', 394 | self.control_file 395 | ) 396 | raise ControlFilePermissionsError() 397 | 398 | def write_result_to_control_file(self): 399 | self.check_control_file_permissions() 400 | try: 401 | with open(self.control_file, 'w') as f: 402 | if self.user_valid: 403 | f.write('1') 404 | else: 405 | f.write('0') 406 | except IOError: 407 | log.critical("Failed to write to OpenVPN control file '{}'".format( 408 | self.control_file 409 | )) 410 | 411 | def run(self): 412 | self.read_configuration_file() 413 | self.load_environment_variables() 414 | self.authenticate() 415 | self.write_result_to_control_file() 416 | 417 | 418 | def return_error_code_for(validator): 419 | if validator.user_valid: 420 | sys.exit(0) 421 | else: 422 | sys.exit(1) 423 | 424 | # This is tested by test_command.sh via tests/test_command.py 425 | if __name__ == "__main__": # pragma: no cover 426 | validator = OktaOpenVPNValidator() 427 | validator.run() 428 | return_error_code_for(validator) 429 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Okta software accompanied by this notice is provided pursuant to the following terms: 2 | 3 | Copyright © 2014, Okta, Inc. 4 | 5 | Licensed under the Mozilla Public License Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at https://www.mozilla.org/MPL/2.0/index.txt 8 | Unless required by applicable law or agreed to in writing, 9 | software distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions 12 | and limitations under the License. 13 | 14 | Mozilla Public License Version 2.0 15 | ================================== 16 | 17 | 1. Definitions 18 | -------------- 19 | 20 | 1.1. "Contributor" 21 | means each individual or legal entity that creates, contributes to 22 | the creation of, or owns Covered Software. 23 | 24 | 1.2. "Contributor Version" 25 | means the combination of the Contributions of others (if any) used 26 | by a Contributor and that particular Contributor's Contribution. 27 | 28 | 1.3. "Contribution" 29 | means Covered Software of a particular Contributor. 30 | 31 | 1.4. "Covered Software" 32 | means Source Code Form to which the initial Contributor has attached 33 | the notice in Exhibit A, the Executable Form of such Source Code 34 | Form, and Modifications of such Source Code Form, in each case 35 | including portions thereof. 36 | 37 | 1.5. "Incompatible With Secondary Licenses" 38 | means 39 | 40 | (a) that the initial Contributor has attached the notice described 41 | in Exhibit B to the Covered Software; or 42 | 43 | (b) that the Covered Software was made available under the terms of 44 | version 1.1 or earlier of the License, but not also under the 45 | terms of a Secondary License. 46 | 47 | 1.6. "Executable Form" 48 | means any form of the work other than Source Code Form. 49 | 50 | 1.7. "Larger Work" 51 | means a work that combines Covered Software with other material, in 52 | a separate file or files, that is not Covered Software. 53 | 54 | 1.8. "License" 55 | means this document. 56 | 57 | 1.9. "Licensable" 58 | means having the right to grant, to the maximum extent possible, 59 | whether at the time of the initial grant or subsequently, any and 60 | all of the rights conveyed by this License. 61 | 62 | 1.10. "Modifications" 63 | means any of the following: 64 | 65 | (a) any file in Source Code Form that results from an addition to, 66 | deletion from, or modification of the contents of Covered 67 | Software; or 68 | 69 | (b) any new file in Source Code Form that contains any Covered 70 | Software. 71 | 72 | 1.11. "Patent Claims" of a Contributor 73 | means any patent claim(s), including without limitation, method, 74 | process, and apparatus claims, in any patent Licensable by such 75 | Contributor that would be infringed, but for the grant of the 76 | License, by the making, using, selling, offering for sale, having 77 | made, import, or transfer of either its Contributions or its 78 | Contributor Version. 79 | 80 | 1.12. "Secondary License" 81 | means either the GNU General Public License, Version 2.0, the GNU 82 | Lesser General Public License, Version 2.1, the GNU Affero General 83 | Public License, Version 3.0, or any later versions of those 84 | licenses. 85 | 86 | 1.13. "Source Code Form" 87 | means the form of the work preferred for making modifications. 88 | 89 | 1.14. "You" (or "Your") 90 | means an individual or a legal entity exercising rights under this 91 | License. For legal entities, "You" includes any entity that 92 | controls, is controlled by, or is under common control with You. For 93 | purposes of this definition, "control" means (a) the power, direct 94 | or indirect, to cause the direction or management of such entity, 95 | whether by contract or otherwise, or (b) ownership of more than 96 | fifty percent (50%) of the outstanding shares or beneficial 97 | ownership of such entity. 98 | 99 | 2. License Grants and Conditions 100 | -------------------------------- 101 | 102 | 2.1. Grants 103 | 104 | Each Contributor hereby grants You a world-wide, royalty-free, 105 | non-exclusive license: 106 | 107 | (a) under intellectual property rights (other than patent or trademark) 108 | Licensable by such Contributor to use, reproduce, make available, 109 | modify, display, perform, distribute, and otherwise exploit its 110 | Contributions, either on an unmodified basis, with Modifications, or 111 | as part of a Larger Work; and 112 | 113 | (b) under Patent Claims of such Contributor to make, use, sell, offer 114 | for sale, have made, import, and otherwise transfer either its 115 | Contributions or its Contributor Version. 116 | 117 | 2.2. Effective Date 118 | 119 | The licenses granted in Section 2.1 with respect to any Contribution 120 | become effective for each Contribution on the date the Contributor first 121 | distributes such Contribution. 122 | 123 | 2.3. Limitations on Grant Scope 124 | 125 | The licenses granted in this Section 2 are the only rights granted under 126 | this License. No additional rights or licenses will be implied from the 127 | distribution or licensing of Covered Software under this License. 128 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 129 | Contributor: 130 | 131 | (a) for any code that a Contributor has removed from Covered Software; 132 | or 133 | 134 | (b) for infringements caused by: (i) Your and any other third party's 135 | modifications of Covered Software, or (ii) the combination of its 136 | Contributions with other software (except as part of its Contributor 137 | Version); or 138 | 139 | (c) under Patent Claims infringed by Covered Software in the absence of 140 | its Contributions. 141 | 142 | This License does not grant any rights in the trademarks, service marks, 143 | or logos of any Contributor (except as may be necessary to comply with 144 | the notice requirements in Section 3.4). 145 | 146 | 2.4. Subsequent Licenses 147 | 148 | No Contributor makes additional grants as a result of Your choice to 149 | distribute the Covered Software under a subsequent version of this 150 | License (see Section 10.2) or under the terms of a Secondary License (if 151 | permitted under the terms of Section 3.3). 152 | 153 | 2.5. Representation 154 | 155 | Each Contributor represents that the Contributor believes its 156 | Contributions are its original creation(s) or it has sufficient rights 157 | to grant the rights to its Contributions conveyed by this License. 158 | 159 | 2.6. Fair Use 160 | 161 | This License is not intended to limit any rights You have under 162 | applicable copyright doctrines of fair use, fair dealing, or other 163 | equivalents. 164 | 165 | 2.7. Conditions 166 | 167 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 168 | in Section 2.1. 169 | 170 | 3. Responsibilities 171 | ------------------- 172 | 173 | 3.1. Distribution of Source Form 174 | 175 | All distribution of Covered Software in Source Code Form, including any 176 | Modifications that You create or to which You contribute, must be under 177 | the terms of this License. You must inform recipients that the Source 178 | Code Form of the Covered Software is governed by the terms of this 179 | License, and how they can obtain a copy of this License. You may not 180 | attempt to alter or restrict the recipients' rights in the Source Code 181 | Form. 182 | 183 | 3.2. Distribution of Executable Form 184 | 185 | If You distribute Covered Software in Executable Form then: 186 | 187 | (a) such Covered Software must also be made available in Source Code 188 | Form, as described in Section 3.1, and You must inform recipients of 189 | the Executable Form how they can obtain a copy of such Source Code 190 | Form by reasonable means in a timely manner, at a charge no more 191 | than the cost of distribution to the recipient; and 192 | 193 | (b) You may distribute such Executable Form under the terms of this 194 | License, or sublicense it under different terms, provided that the 195 | license for the Executable Form does not attempt to limit or alter 196 | the recipients' rights in the Source Code Form under this License. 197 | 198 | 3.3. Distribution of a Larger Work 199 | 200 | You may create and distribute a Larger Work under terms of Your choice, 201 | provided that You also comply with the requirements of this License for 202 | the Covered Software. If the Larger Work is a combination of Covered 203 | Software with a work governed by one or more Secondary Licenses, and the 204 | Covered Software is not Incompatible With Secondary Licenses, this 205 | License permits You to additionally distribute such Covered Software 206 | under the terms of such Secondary License(s), so that the recipient of 207 | the Larger Work may, at their option, further distribute the Covered 208 | Software under the terms of either this License or such Secondary 209 | License(s). 210 | 211 | 3.4. Notices 212 | 213 | You may not remove or alter the substance of any license notices 214 | (including copyright notices, patent notices, disclaimers of warranty, 215 | or limitations of liability) contained within the Source Code Form of 216 | the Covered Software, except that You may alter any license notices to 217 | the extent required to remedy known factual inaccuracies. 218 | 219 | 3.5. Application of Additional Terms 220 | 221 | You may choose to offer, and to charge a fee for, warranty, support, 222 | indemnity or liability obligations to one or more recipients of Covered 223 | Software. However, You may do so only on Your own behalf, and not on 224 | behalf of any Contributor. You must make it absolutely clear that any 225 | such warranty, support, indemnity, or liability obligation is offered by 226 | You alone, and You hereby agree to indemnify every Contributor for any 227 | liability incurred by such Contributor as a result of warranty, support, 228 | indemnity or liability terms You offer. You may include additional 229 | disclaimers of warranty and limitations of liability specific to any 230 | jurisdiction. 231 | 232 | 4. Inability to Comply Due to Statute or Regulation 233 | --------------------------------------------------- 234 | 235 | If it is impossible for You to comply with any of the terms of this 236 | License with respect to some or all of the Covered Software due to 237 | statute, judicial order, or regulation then You must: (a) comply with 238 | the terms of this License to the maximum extent possible; and (b) 239 | describe the limitations and the code they affect. Such description must 240 | be placed in a text file included with all distributions of the Covered 241 | Software under this License. Except to the extent prohibited by statute 242 | or regulation, such description must be sufficiently detailed for a 243 | recipient of ordinary skill to be able to understand it. 244 | 245 | 5. Termination 246 | -------------- 247 | 248 | 5.1. The rights granted under this License will terminate automatically 249 | if You fail to comply with any of its terms. However, if You become 250 | compliant, then the rights granted under this License from a particular 251 | Contributor are reinstated (a) provisionally, unless and until such 252 | Contributor explicitly and finally terminates Your grants, and (b) on an 253 | ongoing basis, if such Contributor fails to notify You of the 254 | non-compliance by some reasonable means prior to 60 days after You have 255 | come back into compliance. Moreover, Your grants from a particular 256 | Contributor are reinstated on an ongoing basis if such Contributor 257 | notifies You of the non-compliance by some reasonable means, this is the 258 | first time You have received notice of non-compliance with this License 259 | from such Contributor, and You become compliant prior to 30 days after 260 | Your receipt of the notice. 261 | 262 | 5.2. If You initiate litigation against any entity by asserting a patent 263 | infringement claim (excluding declaratory judgment actions, 264 | counter-claims, and cross-claims) alleging that a Contributor Version 265 | directly or indirectly infringes any patent, then the rights granted to 266 | You by any and all Contributors for the Covered Software under Section 267 | 2.1 of this License shall terminate. 268 | 269 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 270 | end user license agreements (excluding distributors and resellers) which 271 | have been validly granted by You or Your distributors under this License 272 | prior to termination shall survive termination. 273 | 274 | ************************************************************************ 275 | * * 276 | * 6. Disclaimer of Warranty * 277 | * ------------------------- * 278 | * * 279 | * Covered Software is provided under this License on an "as is" * 280 | * basis, without warranty of any kind, either expressed, implied, or * 281 | * statutory, including, without limitation, warranties that the * 282 | * Covered Software is free of defects, merchantable, fit for a * 283 | * particular purpose or non-infringing. The entire risk as to the * 284 | * quality and performance of the Covered Software is with You. * 285 | * Should any Covered Software prove defective in any respect, You * 286 | * (not any Contributor) assume the cost of any necessary servicing, * 287 | * repair, or correction. This disclaimer of warranty constitutes an * 288 | * essential part of this License. No use of any Covered Software is * 289 | * authorized under this License except under this disclaimer. * 290 | * * 291 | ************************************************************************ 292 | 293 | ************************************************************************ 294 | * * 295 | * 7. Limitation of Liability * 296 | * -------------------------- * 297 | * * 298 | * Under no circumstances and under no legal theory, whether tort * 299 | * (including negligence), contract, or otherwise, shall any * 300 | * Contributor, or anyone who distributes Covered Software as * 301 | * permitted above, be liable to You for any direct, indirect, * 302 | * special, incidental, or consequential damages of any character * 303 | * including, without limitation, damages for lost profits, loss of * 304 | * goodwill, work stoppage, computer failure or malfunction, or any * 305 | * and all other commercial damages or losses, even if such party * 306 | * shall have been informed of the possibility of such damages. This * 307 | * limitation of liability shall not apply to liability for death or * 308 | * personal injury resulting from such party's negligence to the * 309 | * extent applicable law prohibits such limitation. Some * 310 | * jurisdictions do not allow the exclusion or limitation of * 311 | * incidental or consequential damages, so this exclusion and * 312 | * limitation may not apply to You. * 313 | * * 314 | ************************************************************************ 315 | 316 | 8. Litigation 317 | ------------- 318 | 319 | Any litigation relating to this License may be brought only in the 320 | courts of a jurisdiction where the defendant maintains its principal 321 | place of business and such litigation shall be governed by laws of that 322 | jurisdiction, without reference to its conflict-of-law provisions. 323 | Nothing in this Section shall prevent a party's ability to bring 324 | cross-claims or counter-claims. 325 | 326 | 9. Miscellaneous 327 | ---------------- 328 | 329 | This License represents the complete agreement concerning the subject 330 | matter hereof. If any provision of this License is held to be 331 | unenforceable, such provision shall be reformed only to the extent 332 | necessary to make it enforceable. Any law or regulation which provides 333 | that the language of a contract shall be construed against the drafter 334 | shall not be used to construe this License against a Contributor. 335 | 336 | 10. Versions of the License 337 | --------------------------- 338 | 339 | 10.1. New Versions 340 | 341 | Mozilla Foundation is the license steward. Except as provided in Section 342 | 10.3, no one other than the license steward has the right to modify or 343 | publish new versions of this License. Each version will be given a 344 | distinguishing version number. 345 | 346 | 10.2. Effect of New Versions 347 | 348 | You may distribute the Covered Software under the terms of the version 349 | of the License under which You originally received the Covered Software, 350 | or under the terms of any subsequent version published by the license 351 | steward. 352 | 353 | 10.3. Modified Versions 354 | 355 | If you create software not governed by this License, and you want to 356 | create a new license for such software, you may create and use a 357 | modified version of this License if you rename the license and remove 358 | any references to the name of the license steward (except to note that 359 | such modified license differs from this License). 360 | 361 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 362 | Licenses 363 | 364 | If You choose to distribute Source Code Form that is Incompatible With 365 | Secondary Licenses under the terms of this version of the License, the 366 | notice described in Exhibit B of this License must be attached. 367 | 368 | Exhibit A - Source Code Form License Notice 369 | ------------------------------------------- 370 | 371 | This Source Code Form is subject to the terms of the Mozilla Public 372 | License, v. 2.0. If a copy of the MPL was not distributed with this 373 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 374 | 375 | If it is not possible or desirable to put the notice in a particular 376 | file, then You may include the notice in a location (such as a LICENSE 377 | file in a relevant directory) where a recipient would be likely to look 378 | for such a notice. 379 | 380 | You may add additional accurate notices of copyright ownership. 381 | 382 | Exhibit B - "Incompatible With Secondary Licenses" Notice 383 | --------------------------------------------------------- 384 | 385 | This Source Code Form is "Incompatible With Secondary Licenses", as 386 | defined by the Mozilla Public License, v. 2.0. 387 | -------------------------------------------------------------------------------- /openvpn-plugin.h: -------------------------------------------------------------------------------- 1 | /* 2 | * OpenVPN -- An application to securely tunnel IP networks 3 | * over a single TCP/UDP port, with support for SSL/TLS-based 4 | * session authentication and key exchange, 5 | * packet encryption, packet authentication, and 6 | * packet compression. 7 | * 8 | * Copyright (C) 2002-2010 OpenVPN Technologies, Inc. 9 | * 10 | * This program is free software; you can redistribute it and/or modify 11 | * it under the terms of the GNU General Public License version 2 12 | * as published by the Free Software Foundation. 13 | * 14 | * This program is distributed in the hope that it will be useful, 15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | * GNU General Public License for more details. 18 | * 19 | * You should have received a copy of the GNU General Public License 20 | * along with this program (see the file COPYING included with this 21 | * distribution); if not, write to the Free Software Foundation, Inc., 22 | * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 23 | */ 24 | 25 | #ifndef OPENVPN_PLUGIN_H_ 26 | #define OPENVPN_PLUGIN_H_ 27 | 28 | #define OPENVPN_PLUGIN_VERSION 3 29 | 30 | #ifdef ENABLE_SSL 31 | #ifdef ENABLE_CRYPTO_POLARSSL 32 | #include 33 | #ifndef __OPENVPN_X509_CERT_T_DECLARED 34 | #define __OPENVPN_X509_CERT_T_DECLARED 35 | typedef x509_cert openvpn_x509_cert_t; 36 | #endif 37 | #else 38 | #include 39 | #ifndef __OPENVPN_X509_CERT_T_DECLARED 40 | #define __OPENVPN_X509_CERT_T_DECLARED 41 | typedef X509 openvpn_x509_cert_t; 42 | #endif 43 | #endif 44 | #endif 45 | 46 | #include 47 | 48 | #ifdef __cplusplus 49 | extern "C" { 50 | #endif 51 | 52 | /* 53 | * Plug-in types. These types correspond to the set of script callbacks 54 | * supported by OpenVPN. 55 | * 56 | * This is the general call sequence to expect when running in server mode: 57 | * 58 | * Initial Server Startup: 59 | * 60 | * FUNC: openvpn_plugin_open_v1 61 | * FUNC: openvpn_plugin_client_constructor_v1 (this is the top-level "generic" 62 | * client template) 63 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_UP 64 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_ROUTE_UP 65 | * 66 | * New Client Connection: 67 | * 68 | * FUNC: openvpn_plugin_client_constructor_v1 69 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_ENABLE_PF 70 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_TLS_VERIFY (called once for every cert 71 | * in the server chain) 72 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY 73 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_TLS_FINAL 74 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_IPCHANGE 75 | * 76 | * [If OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY returned OPENVPN_PLUGIN_FUNC_DEFERRED, 77 | * we don't proceed until authentication is verified via auth_control_file] 78 | * 79 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_CLIENT_CONNECT_V2 80 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_LEARN_ADDRESS 81 | * 82 | * [Client session ensues] 83 | * 84 | * For each "TLS soft reset", according to reneg-sec option (or similar): 85 | * 86 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_ENABLE_PF 87 | * 88 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_TLS_VERIFY (called once for every cert 89 | * in the server chain) 90 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY 91 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_TLS_FINAL 92 | * 93 | * [If OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY returned OPENVPN_PLUGIN_FUNC_DEFERRED, 94 | * we expect that authentication is verified via auth_control_file within 95 | * the number of seconds defined by the "hand-window" option. Data channel traffic 96 | * will continue to flow uninterrupted during this period.] 97 | * 98 | * [Client session continues] 99 | * 100 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_CLIENT_DISCONNECT 101 | * FUNC: openvpn_plugin_client_destructor_v1 102 | * 103 | * [ some time may pass ] 104 | * 105 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_LEARN_ADDRESS (this coincides with a 106 | * lazy free of initial 107 | * learned addr object) 108 | * Server Shutdown: 109 | * 110 | * FUNC: openvpn_plugin_func_v1 OPENVPN_PLUGIN_DOWN 111 | * FUNC: openvpn_plugin_client_destructor_v1 (top-level "generic" client) 112 | * FUNC: openvpn_plugin_close_v1 113 | */ 114 | #define OPENVPN_PLUGIN_UP 0 115 | #define OPENVPN_PLUGIN_DOWN 1 116 | #define OPENVPN_PLUGIN_ROUTE_UP 2 117 | #define OPENVPN_PLUGIN_IPCHANGE 3 118 | #define OPENVPN_PLUGIN_TLS_VERIFY 4 119 | #define OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY 5 120 | #define OPENVPN_PLUGIN_CLIENT_CONNECT 6 121 | #define OPENVPN_PLUGIN_CLIENT_DISCONNECT 7 122 | #define OPENVPN_PLUGIN_LEARN_ADDRESS 8 123 | #define OPENVPN_PLUGIN_CLIENT_CONNECT_V2 9 124 | #define OPENVPN_PLUGIN_TLS_FINAL 10 125 | #define OPENVPN_PLUGIN_ENABLE_PF 11 126 | #define OPENVPN_PLUGIN_ROUTE_PREDOWN 12 127 | #define OPENVPN_PLUGIN_N 13 128 | 129 | /* 130 | * Build a mask out of a set of plug-in types. 131 | */ 132 | #define OPENVPN_PLUGIN_MASK(x) (1<<(x)) 133 | 134 | /* 135 | * A pointer to a plugin-defined object which contains 136 | * the object state. 137 | */ 138 | typedef void *openvpn_plugin_handle_t; 139 | 140 | /* 141 | * Return value for openvpn_plugin_func_v1 function 142 | */ 143 | #define OPENVPN_PLUGIN_FUNC_SUCCESS 0 144 | #define OPENVPN_PLUGIN_FUNC_ERROR 1 145 | #define OPENVPN_PLUGIN_FUNC_DEFERRED 2 146 | 147 | /* 148 | * For Windows (needs to be modified for MSVC) 149 | */ 150 | #if defined(WIN32) && !defined(OPENVPN_PLUGIN_H) 151 | # define OPENVPN_EXPORT __declspec(dllexport) 152 | #else 153 | # define OPENVPN_EXPORT 154 | #endif 155 | 156 | /* 157 | * If OPENVPN_PLUGIN_H is defined, we know that we are being 158 | * included in an OpenVPN compile, rather than a plugin compile. 159 | */ 160 | #ifdef OPENVPN_PLUGIN_H 161 | 162 | /* 163 | * We are compiling OpenVPN. 164 | */ 165 | #define OPENVPN_PLUGIN_DEF typedef 166 | #define OPENVPN_PLUGIN_FUNC(name) (*name) 167 | 168 | #else 169 | 170 | /* 171 | * We are compiling plugin. 172 | */ 173 | #define OPENVPN_PLUGIN_DEF OPENVPN_EXPORT 174 | #define OPENVPN_PLUGIN_FUNC(name) name 175 | 176 | #endif 177 | 178 | /* 179 | * Used by openvpn_plugin_func to return structured 180 | * data. The plugin should allocate all structure 181 | * instances, name strings, and value strings with 182 | * malloc, since OpenVPN will assume that it 183 | * can free the list by calling free() over the same. 184 | */ 185 | struct openvpn_plugin_string_list 186 | { 187 | struct openvpn_plugin_string_list *next; 188 | char *name; 189 | char *value; 190 | }; 191 | 192 | 193 | /* openvpn_plugin_{open,func}_v3() related structs */ 194 | 195 | /* Defines version of the v3 plugin argument structs 196 | * 197 | * Whenever one or more of these structs are modified, this constant 198 | * must be updated. A changelog should be appended in this comment 199 | * as well, to make it easier to see what information is available 200 | * in the different versions. 201 | * 202 | * Version Comment 203 | * 1 Initial plugin v3 structures providing the same API as 204 | * the v2 plugin interface, X509 certificate information + 205 | * a logging API for plug-ins. 206 | * 207 | * 2 Added ssl_api member in struct openvpn_plugin_args_open_in 208 | * which identifies the SSL implementation OpenVPN is compiled 209 | * against. 210 | * 211 | */ 212 | #define OPENVPN_PLUGINv3_STRUCTVER 2 213 | 214 | /** 215 | * Definitions needed for the plug-in callback functions. 216 | */ 217 | typedef enum 218 | { 219 | PLOG_ERR = (1 << 0), /* Error condition message */ 220 | PLOG_WARN = (1 << 1), /* General warning message */ 221 | PLOG_NOTE = (1 << 2), /* Informational message */ 222 | PLOG_DEBUG = (1 << 3), /* Debug message, displayed if verb >= 7 */ 223 | 224 | PLOG_ERRNO = (1 << 8), /* Add error description to message */ 225 | PLOG_NOMUTE = (1 << 9), /* Mute setting does not apply for message */ 226 | 227 | } openvpn_plugin_log_flags_t; 228 | 229 | 230 | #ifdef __GNUC__ 231 | #if __USE_MINGW_ANSI_STDIO 232 | # define _ovpn_chk_fmt(a, b) __attribute__ ((format(gnu_printf, (a), (b)))) 233 | #else 234 | # define _ovpn_chk_fmt(a, b) __attribute__ ((format(__printf__, (a), (b)))) 235 | #endif 236 | #else 237 | # define _ovpn_chk_fmt(a, b) 238 | #endif 239 | 240 | typedef void (*plugin_log_t) (openvpn_plugin_log_flags_t flags, 241 | const char *plugin_name, 242 | const char *format, ...) _ovpn_chk_fmt(3, 4); 243 | 244 | typedef void (*plugin_vlog_t) (openvpn_plugin_log_flags_t flags, 245 | const char *plugin_name, 246 | const char *format, 247 | va_list arglist) _ovpn_chk_fmt(3, 0); 248 | 249 | #undef _ovpn_chk_fmt 250 | 251 | /** 252 | * Used by the openvpn_plugin_open_v3() function to pass callback 253 | * function pointers to the plug-in. 254 | * 255 | * plugin_log 256 | * plugin_vlog : Use these functions to add information to the OpenVPN log file. 257 | * Messages will only be displayed if the plugin_name parameter 258 | * is set. PLOG_DEBUG messages will only be displayed with plug-in 259 | * debug log verbosity (at the time of writing that's verb >= 7). 260 | */ 261 | struct openvpn_plugin_callbacks 262 | { 263 | plugin_log_t plugin_log; 264 | plugin_vlog_t plugin_vlog; 265 | }; 266 | 267 | /** 268 | * Used by the openvpn_plugin_open_v3() function to indicate to the 269 | * plug-in what kind of SSL implementation OpenVPN uses. This is 270 | * to avoid SEGV issues when OpenVPN is complied against PolarSSL 271 | * and the plug-in against OpenSSL. 272 | */ 273 | typedef enum { 274 | SSLAPI_NONE, 275 | SSLAPI_OPENSSL, 276 | SSLAPI_POLARSSL 277 | } ovpnSSLAPI; 278 | 279 | /** 280 | * Arguments used to transport variables to the plug-in. 281 | * The struct openvpn_plugin_args_open_in is only used 282 | * by the openvpn_plugin_open_v3() function. 283 | * 284 | * STRUCT MEMBERS 285 | * 286 | * type_mask : Set by OpenVPN to the logical OR of all script 287 | * types which this version of OpenVPN supports. 288 | * 289 | * argv : a NULL-terminated array of options provided to the OpenVPN 290 | * "plug-in" directive. argv[0] is the dynamic library pathname. 291 | * 292 | * envp : a NULL-terminated array of OpenVPN-set environmental 293 | * variables in "name=value" format. Note that for security reasons, 294 | * these variables are not actually written to the "official" 295 | * environmental variable store of the process. 296 | * 297 | * callbacks : a pointer to the plug-in callback function struct. 298 | * 299 | */ 300 | struct openvpn_plugin_args_open_in 301 | { 302 | const int type_mask; 303 | const char ** const argv; 304 | const char ** const envp; 305 | struct openvpn_plugin_callbacks *callbacks; 306 | const ovpnSSLAPI ssl_api; 307 | }; 308 | 309 | 310 | /** 311 | * Arguments used to transport variables from the plug-in back 312 | * to the OpenVPN process. The struct openvpn_plugin_args_open_return 313 | * is only used by the openvpn_plugin_open_v3() function. 314 | * 315 | * STRUCT MEMBERS 316 | * 317 | * *type_mask : The plug-in should set this value to the logical OR of all script 318 | * types which the plug-in wants to intercept. For example, if the 319 | * script wants to intercept the client-connect and client-disconnect 320 | * script types: 321 | * 322 | * *type_mask = OPENVPN_PLUGIN_MASK(OPENVPN_PLUGIN_CLIENT_CONNECT) 323 | * | OPENVPN_PLUGIN_MASK(OPENVPN_PLUGIN_CLIENT_DISCONNECT) 324 | * 325 | * *handle : Pointer to a global plug-in context, created by the plug-in. This pointer 326 | * is passed on to the other plug-in calls. 327 | * 328 | * return_list : used to return data back to OpenVPN. 329 | * 330 | */ 331 | struct openvpn_plugin_args_open_return 332 | { 333 | int type_mask; 334 | openvpn_plugin_handle_t *handle; 335 | struct openvpn_plugin_string_list **return_list; 336 | }; 337 | 338 | /** 339 | * Arguments used to transport variables to and from the 340 | * plug-in. The struct openvpn_plugin_args_func is only used 341 | * by the openvpn_plugin_func_v3() function. 342 | * 343 | * STRUCT MEMBERS: 344 | * 345 | * type : one of the PLUGIN_x types. 346 | * 347 | * argv : a NULL-terminated array of "command line" options which 348 | * would normally be passed to the script. argv[0] is the dynamic 349 | * library pathname. 350 | * 351 | * envp : a NULL-terminated array of OpenVPN-set environmental 352 | * variables in "name=value" format. Note that for security reasons, 353 | * these variables are not actually written to the "official" 354 | * environmental variable store of the process. 355 | * 356 | * *handle : Pointer to a global plug-in context, created by the plug-in's openvpn_plugin_open_v3(). 357 | * 358 | * *per_client_context : the per-client context pointer which was returned by 359 | * openvpn_plugin_client_constructor_v1, if defined. 360 | * 361 | * current_cert_depth : Certificate depth of the certificate being passed over (only if compiled with ENABLE_SSL defined) 362 | * 363 | * *current_cert : X509 Certificate object received from the client (only if compiled with ENABLE_SSL defined) 364 | * 365 | */ 366 | struct openvpn_plugin_args_func_in 367 | { 368 | const int type; 369 | const char ** const argv; 370 | const char ** const envp; 371 | openvpn_plugin_handle_t handle; 372 | void *per_client_context; 373 | #ifdef ENABLE_SSL 374 | int current_cert_depth; 375 | openvpn_x509_cert_t *current_cert; 376 | #else 377 | int __current_cert_depth_disabled; /* Unused, for compatibility purposes only */ 378 | void *__current_cert_disabled; /* Unused, for compatibility purposes only */ 379 | #endif 380 | }; 381 | 382 | 383 | /** 384 | * Arguments used to transport variables to and from the 385 | * plug-in. The struct openvpn_plugin_args_func is only used 386 | * by the openvpn_plugin_func_v3() function. 387 | * 388 | * STRUCT MEMBERS: 389 | * 390 | * return_list : used to return data back to OpenVPN for further processing/usage by 391 | * the OpenVPN executable. 392 | * 393 | */ 394 | struct openvpn_plugin_args_func_return 395 | { 396 | struct openvpn_plugin_string_list **return_list; 397 | }; 398 | 399 | /* 400 | * Multiple plugin modules can be cascaded, and modules can be 401 | * used in tandem with scripts. The order of operation is that 402 | * the module func() functions are called in the order that 403 | * the modules were specified in the config file. If a script 404 | * was specified as well, it will be called last. If the 405 | * return code of the module/script controls an authentication 406 | * function (such as tls-verify or auth-user-pass-verify), then 407 | * every module and script must return success (0) in order for 408 | * the connection to be authenticated. 409 | * 410 | * Notes: 411 | * 412 | * Plugins which use a privilege-separation model (by forking in 413 | * their initialization function before the main OpenVPN process 414 | * downgrades root privileges and/or executes a chroot) must 415 | * daemonize after a fork if the "daemon" environmental variable is 416 | * set. In addition, if the "daemon_log_redirect" variable is set, 417 | * the plugin should preserve stdout/stderr across the daemon() 418 | * syscall. See the daemonize() function in plugin/auth-pam/auth-pam.c 419 | * for an example. 420 | */ 421 | 422 | /* 423 | * Prototypes for functions which OpenVPN plug-ins must define. 424 | */ 425 | 426 | /* 427 | * FUNCTION: openvpn_plugin_open_v2 428 | * 429 | * REQUIRED: YES 430 | * 431 | * Called on initial plug-in load. OpenVPN will preserve plug-in state 432 | * across SIGUSR1 restarts but not across SIGHUP restarts. A SIGHUP reset 433 | * will cause the plugin to be closed and reopened. 434 | * 435 | * ARGUMENTS 436 | * 437 | * *type_mask : Set by OpenVPN to the logical OR of all script 438 | * types which this version of OpenVPN supports. The plug-in 439 | * should set this value to the logical OR of all script types 440 | * which the plug-in wants to intercept. For example, if the 441 | * script wants to intercept the client-connect and 442 | * client-disconnect script types: 443 | * 444 | * *type_mask = OPENVPN_PLUGIN_MASK(OPENVPN_PLUGIN_CLIENT_CONNECT) 445 | * | OPENVPN_PLUGIN_MASK(OPENVPN_PLUGIN_CLIENT_DISCONNECT) 446 | * 447 | * argv : a NULL-terminated array of options provided to the OpenVPN 448 | * "plug-in" directive. argv[0] is the dynamic library pathname. 449 | * 450 | * envp : a NULL-terminated array of OpenVPN-set environmental 451 | * variables in "name=value" format. Note that for security reasons, 452 | * these variables are not actually written to the "official" 453 | * environmental variable store of the process. 454 | * 455 | * return_list : used to return data back to OpenVPN. 456 | * 457 | * RETURN VALUE 458 | * 459 | * An openvpn_plugin_handle_t value on success, NULL on failure 460 | */ 461 | OPENVPN_PLUGIN_DEF openvpn_plugin_handle_t OPENVPN_PLUGIN_FUNC(openvpn_plugin_open_v2) 462 | (unsigned int *type_mask, 463 | const char *argv[], 464 | const char *envp[], 465 | struct openvpn_plugin_string_list **return_list); 466 | 467 | /* 468 | * FUNCTION: openvpn_plugin_func_v2 469 | * 470 | * Called to perform the work of a given script type. 471 | * 472 | * REQUIRED: YES 473 | * 474 | * ARGUMENTS 475 | * 476 | * handle : the openvpn_plugin_handle_t value which was returned by 477 | * openvpn_plugin_open. 478 | * 479 | * type : one of the PLUGIN_x types 480 | * 481 | * argv : a NULL-terminated array of "command line" options which 482 | * would normally be passed to the script. argv[0] is the dynamic 483 | * library pathname. 484 | * 485 | * envp : a NULL-terminated array of OpenVPN-set environmental 486 | * variables in "name=value" format. Note that for security reasons, 487 | * these variables are not actually written to the "official" 488 | * environmental variable store of the process. 489 | * 490 | * per_client_context : the per-client context pointer which was returned by 491 | * openvpn_plugin_client_constructor_v1, if defined. 492 | * 493 | * return_list : used to return data back to OpenVPN. 494 | * 495 | * RETURN VALUE 496 | * 497 | * OPENVPN_PLUGIN_FUNC_SUCCESS on success, OPENVPN_PLUGIN_FUNC_ERROR on failure 498 | * 499 | * In addition, OPENVPN_PLUGIN_FUNC_DEFERRED may be returned by 500 | * OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY. This enables asynchronous 501 | * authentication where the plugin (or one of its agents) may indicate 502 | * authentication success/failure some number of seconds after the return 503 | * of the OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY handler by writing a single 504 | * char to the file named by auth_control_file in the environmental variable 505 | * list (envp). 506 | * 507 | * first char of auth_control_file: 508 | * '0' -- indicates auth failure 509 | * '1' -- indicates auth success 510 | * 511 | * OpenVPN will delete the auth_control_file after it goes out of scope. 512 | * 513 | * If an OPENVPN_PLUGIN_ENABLE_PF handler is defined and returns success 514 | * for a particular client instance, packet filtering will be enabled for that 515 | * instance. OpenVPN will then attempt to read the packet filter configuration 516 | * from the temporary file named by the environmental variable pf_file. This 517 | * file may be generated asynchronously and may be dynamically updated during the 518 | * client session, however the client will be blocked from sending or receiving 519 | * VPN tunnel packets until the packet filter file has been generated. OpenVPN 520 | * will periodically test the packet filter file over the life of the client 521 | * instance and reload when modified. OpenVPN will delete the packet filter file 522 | * when the client instance goes out of scope. 523 | * 524 | * Packet filter file grammar: 525 | * 526 | * [CLIENTS DROP|ACCEPT] 527 | * {+|-}common_name1 528 | * {+|-}common_name2 529 | * . . . 530 | * [SUBNETS DROP|ACCEPT] 531 | * {+|-}subnet1 532 | * {+|-}subnet2 533 | * . . . 534 | * [END] 535 | * 536 | * Subnet: IP-ADDRESS | IP-ADDRESS/NUM_NETWORK_BITS 537 | * 538 | * CLIENTS refers to the set of clients (by their common-name) which 539 | * this instance is allowed ('+') to connect to, or is excluded ('-') 540 | * from connecting to. Note that in the case of client-to-client 541 | * connections, such communication must be allowed by the packet filter 542 | * configuration files of both clients. 543 | * 544 | * SUBNETS refers to IP addresses or IP address subnets which this 545 | * instance may connect to ('+') or is excluded ('-') from connecting 546 | * to. 547 | * 548 | * DROP or ACCEPT defines default policy when there is no explicit match 549 | * for a common-name or subnet. The [END] tag must exist. A special 550 | * purpose tag called [KILL] will immediately kill the client instance. 551 | * A given client or subnet rule applies to both incoming and outgoing 552 | * packets. 553 | * 554 | * See plugin/defer/simple.c for an example on using asynchronous 555 | * authentication and client-specific packet filtering. 556 | */ 557 | OPENVPN_PLUGIN_DEF int OPENVPN_PLUGIN_FUNC(openvpn_plugin_func_v2) 558 | (openvpn_plugin_handle_t handle, 559 | const int type, 560 | const char *argv[], 561 | const char *envp[], 562 | void *per_client_context, 563 | struct openvpn_plugin_string_list **return_list); 564 | 565 | 566 | /* 567 | * FUNCTION: openvpn_plugin_open_v3 568 | * 569 | * REQUIRED: YES 570 | * 571 | * Called on initial plug-in load. OpenVPN will preserve plug-in state 572 | * across SIGUSR1 restarts but not across SIGHUP restarts. A SIGHUP reset 573 | * will cause the plugin to be closed and reopened. 574 | * 575 | * ARGUMENTS 576 | * 577 | * version : fixed value, defines the API version of the OpenVPN plug-in API. The plug-in 578 | * should validate that this value is matching the OPENVPN_PLUGINv3_STRUCTVER 579 | * value. 580 | * 581 | * arguments : Structure with all arguments available to the plug-in. 582 | * 583 | * retptr : used to return data back to OpenVPN. 584 | * 585 | * RETURN VALUE 586 | * 587 | * OPENVPN_PLUGIN_FUNC_SUCCESS on success, OPENVPN_PLUGIN_FUNC_ERROR on failure 588 | */ 589 | OPENVPN_PLUGIN_DEF int OPENVPN_PLUGIN_FUNC(openvpn_plugin_open_v3) 590 | (const int version, 591 | struct openvpn_plugin_args_open_in const *arguments, 592 | struct openvpn_plugin_args_open_return *retptr); 593 | 594 | /* 595 | * FUNCTION: openvpn_plugin_func_v3 596 | * 597 | * Called to perform the work of a given script type. 598 | * 599 | * REQUIRED: YES 600 | * 601 | * ARGUMENTS 602 | * 603 | * version : fixed value, defines the API version of the OpenVPN plug-in API. The plug-in 604 | * should validate that this value is matching the OPENVPN_PLUGIN_VERSION value. 605 | * 606 | * handle : the openvpn_plugin_handle_t value which was returned by 607 | * openvpn_plugin_open. 608 | * 609 | * return_list : used to return data back to OpenVPN. 610 | * 611 | * RETURN VALUE 612 | * 613 | * OPENVPN_PLUGIN_FUNC_SUCCESS on success, OPENVPN_PLUGIN_FUNC_ERROR on failure 614 | * 615 | * In addition, OPENVPN_PLUGIN_FUNC_DEFERRED may be returned by 616 | * OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY. This enables asynchronous 617 | * authentication where the plugin (or one of its agents) may indicate 618 | * authentication success/failure some number of seconds after the return 619 | * of the OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY handler by writing a single 620 | * char to the file named by auth_control_file in the environmental variable 621 | * list (envp). 622 | * 623 | * first char of auth_control_file: 624 | * '0' -- indicates auth failure 625 | * '1' -- indicates auth success 626 | * 627 | * OpenVPN will delete the auth_control_file after it goes out of scope. 628 | * 629 | * If an OPENVPN_PLUGIN_ENABLE_PF handler is defined and returns success 630 | * for a particular client instance, packet filtering will be enabled for that 631 | * instance. OpenVPN will then attempt to read the packet filter configuration 632 | * from the temporary file named by the environmental variable pf_file. This 633 | * file may be generated asynchronously and may be dynamically updated during the 634 | * client session, however the client will be blocked from sending or receiving 635 | * VPN tunnel packets until the packet filter file has been generated. OpenVPN 636 | * will periodically test the packet filter file over the life of the client 637 | * instance and reload when modified. OpenVPN will delete the packet filter file 638 | * when the client instance goes out of scope. 639 | * 640 | * Packet filter file grammar: 641 | * 642 | * [CLIENTS DROP|ACCEPT] 643 | * {+|-}common_name1 644 | * {+|-}common_name2 645 | * . . . 646 | * [SUBNETS DROP|ACCEPT] 647 | * {+|-}subnet1 648 | * {+|-}subnet2 649 | * . . . 650 | * [END] 651 | * 652 | * Subnet: IP-ADDRESS | IP-ADDRESS/NUM_NETWORK_BITS 653 | * 654 | * CLIENTS refers to the set of clients (by their common-name) which 655 | * this instance is allowed ('+') to connect to, or is excluded ('-') 656 | * from connecting to. Note that in the case of client-to-client 657 | * connections, such communication must be allowed by the packet filter 658 | * configuration files of both clients. 659 | * 660 | * SUBNETS refers to IP addresses or IP address subnets which this 661 | * instance may connect to ('+') or is excluded ('-') from connecting 662 | * to. 663 | * 664 | * DROP or ACCEPT defines default policy when there is no explicit match 665 | * for a common-name or subnet. The [END] tag must exist. A special 666 | * purpose tag called [KILL] will immediately kill the client instance. 667 | * A given client or subnet rule applies to both incoming and outgoing 668 | * packets. 669 | * 670 | * See plugin/defer/simple.c for an example on using asynchronous 671 | * authentication and client-specific packet filtering. 672 | */ 673 | OPENVPN_PLUGIN_DEF int OPENVPN_PLUGIN_FUNC(openvpn_plugin_func_v3) 674 | (const int version, 675 | struct openvpn_plugin_args_func_in const *arguments, 676 | struct openvpn_plugin_args_func_return *retptr); 677 | 678 | /* 679 | * FUNCTION: openvpn_plugin_close_v1 680 | * 681 | * REQUIRED: YES 682 | * 683 | * ARGUMENTS 684 | * 685 | * handle : the openvpn_plugin_handle_t value which was returned by 686 | * openvpn_plugin_open. 687 | * 688 | * Called immediately prior to plug-in unload. 689 | */ 690 | OPENVPN_PLUGIN_DEF void OPENVPN_PLUGIN_FUNC(openvpn_plugin_close_v1) 691 | (openvpn_plugin_handle_t handle); 692 | 693 | /* 694 | * FUNCTION: openvpn_plugin_abort_v1 695 | * 696 | * REQUIRED: NO 697 | * 698 | * ARGUMENTS 699 | * 700 | * handle : the openvpn_plugin_handle_t value which was returned by 701 | * openvpn_plugin_open. 702 | * 703 | * Called when OpenVPN is in the process of aborting due to a fatal error. 704 | * Will only be called on an open context returned by a prior successful 705 | * openvpn_plugin_open callback. 706 | */ 707 | OPENVPN_PLUGIN_DEF void OPENVPN_PLUGIN_FUNC(openvpn_plugin_abort_v1) 708 | (openvpn_plugin_handle_t handle); 709 | 710 | /* 711 | * FUNCTION: openvpn_plugin_client_constructor_v1 712 | * 713 | * Called to allocate a per-client memory region, which 714 | * is then passed to the openvpn_plugin_func_v2 function. 715 | * This function is called every time the OpenVPN server 716 | * constructs a client instance object, which normally 717 | * occurs when a session-initiating packet is received 718 | * by a new client, even before the client has authenticated. 719 | * 720 | * This function should allocate the private memory needed 721 | * by the plugin to track individual OpenVPN clients, and 722 | * return a void * to this memory region. 723 | * 724 | * REQUIRED: NO 725 | * 726 | * ARGUMENTS 727 | * 728 | * handle : the openvpn_plugin_handle_t value which was returned by 729 | * openvpn_plugin_open. 730 | * 731 | * RETURN VALUE 732 | * 733 | * void * pointer to plugin's private per-client memory region, or NULL 734 | * if no memory region is required. 735 | */ 736 | OPENVPN_PLUGIN_DEF void * OPENVPN_PLUGIN_FUNC(openvpn_plugin_client_constructor_v1) 737 | (openvpn_plugin_handle_t handle); 738 | 739 | /* 740 | * FUNCTION: openvpn_plugin_client_destructor_v1 741 | * 742 | * This function is called on client instance object destruction. 743 | * 744 | * REQUIRED: NO 745 | * 746 | * ARGUMENTS 747 | * 748 | * handle : the openvpn_plugin_handle_t value which was returned by 749 | * openvpn_plugin_open. 750 | * 751 | * per_client_context : the per-client context pointer which was returned by 752 | * openvpn_plugin_client_constructor_v1, if defined. 753 | */ 754 | OPENVPN_PLUGIN_DEF void OPENVPN_PLUGIN_FUNC(openvpn_plugin_client_destructor_v1) 755 | (openvpn_plugin_handle_t handle, void *per_client_context); 756 | 757 | /* 758 | * FUNCTION: openvpn_plugin_select_initialization_point_v1 759 | * 760 | * Several different points exist in OpenVPN's initialization sequence where 761 | * the openvpn_plugin_open function can be called. While the default is 762 | * OPENVPN_PLUGIN_INIT_PRE_DAEMON, this function can be used to select a 763 | * different initialization point. For example, if your plugin needs to 764 | * return configuration parameters to OpenVPN, use 765 | * OPENVPN_PLUGIN_INIT_PRE_CONFIG_PARSE. 766 | * 767 | * REQUIRED: NO 768 | * 769 | * RETURN VALUE: 770 | * 771 | * An OPENVPN_PLUGIN_INIT_x value. 772 | */ 773 | #define OPENVPN_PLUGIN_INIT_PRE_CONFIG_PARSE 1 774 | #define OPENVPN_PLUGIN_INIT_PRE_DAEMON 2 /* default */ 775 | #define OPENVPN_PLUGIN_INIT_POST_DAEMON 3 776 | #define OPENVPN_PLUGIN_INIT_POST_UID_CHANGE 4 777 | 778 | OPENVPN_PLUGIN_DEF int OPENVPN_PLUGIN_FUNC(openvpn_plugin_select_initialization_point_v1) 779 | (void); 780 | 781 | /* 782 | * FUNCTION: openvpn_plugin_min_version_required_v1 783 | * 784 | * This function is called by OpenVPN to query the minimum 785 | plugin interface version number required by the plugin. 786 | * 787 | * REQUIRED: NO 788 | * 789 | * RETURN VALUE 790 | * 791 | * The minimum OpenVPN plugin interface version number necessary to support 792 | * this plugin. 793 | */ 794 | OPENVPN_PLUGIN_DEF int OPENVPN_PLUGIN_FUNC(openvpn_plugin_min_version_required_v1) 795 | (void); 796 | 797 | /* 798 | * Deprecated functions which are still supported for backward compatibility. 799 | */ 800 | 801 | OPENVPN_PLUGIN_DEF openvpn_plugin_handle_t OPENVPN_PLUGIN_FUNC(openvpn_plugin_open_v1) 802 | (unsigned int *type_mask, 803 | const char *argv[], 804 | const char *envp[]); 805 | 806 | OPENVPN_PLUGIN_DEF int OPENVPN_PLUGIN_FUNC(openvpn_plugin_func_v1) 807 | (openvpn_plugin_handle_t handle, const int type, const char *argv[], const char *envp[]); 808 | 809 | #ifdef __cplusplus 810 | } 811 | #endif 812 | 813 | #endif /* OPENVPN_PLUGIN_H_ */ 814 | -------------------------------------------------------------------------------- /CODE.org: -------------------------------------------------------------------------------- 1 | * Version :noexport: 2 | #+NAME: version 3 | #+BEGIN_SRC text 4 | 0.10.1-beta 5 | #+END_SRC 6 | * An overview of how okta-openvpn works 7 | 8 | This is a plugin for OpenVPN Community Edition that allows OpenVPN 9 | to authenticate directly against Okta, with support for TOTP and Okta 10 | Verify Push factors. 11 | 12 | At a high level, OpenVPN communicates with this plugin via a 13 | "control file", a temporary file that OpenVPN creates and polls 14 | periodicly. If the plugin writes the ASCII character "1" into the 15 | control file, the user in question is allowed to log in to OpenVPN, 16 | if we write the ASCII character "0" into the file, the user is 17 | denied. 18 | 19 | Below are the key parts of the code for =okta_openvpn.py=: 20 | 21 | 1. Instantiate an OktaOpenVPNValidator object 22 | 2. Load in configuration file and environment variables 23 | 3. Authenticate the user 24 | 4. Write result to the control file 25 | 26 | ** Instantiate an OktaOpenVPNValidator object 27 | 28 | The code flow for authenticating a user is as follows: 29 | 30 | 31 | Here is how we instantiate an OktaOpenVPNValidator object: 32 | #+BEGIN_SRC python :noweb yes 33 | <> 34 | #+END_SRC 35 | 36 | ** Load in configuration file and environment variables 37 | Here is the =run()= method of the OktaOpenVPNValidator class, this 38 | is what calls the methods which load the configuration file and 39 | environment variables, then calls the =authenticate()= method. 40 | 41 | #+BEGIN_SRC python :noweb yes 42 | <> 43 | #+END_SRC 44 | 45 | ** Authenticate the user 46 | Here is the =authenticate()= method: 47 | #+NAME: validator-authenticate 48 | #+BEGIN_SRC python 49 | def authenticate(self): 50 | if not self.username_trusted: 51 | log.warning("Username %s is not trusted - failing", 52 | self.okta_config['username']) 53 | return False 54 | try: 55 | okta = self.cls(**self.okta_config) 56 | self.user_valid = okta.auth() 57 | return self.user_valid 58 | except Exception as exception: 59 | log.error( 60 | "User %s (%s) authentication failed, " 61 | "because %s() failed unexpectedly - %s", 62 | self.okta_config['username'], 63 | self.okta_config['client_ipaddr'], 64 | self.cls.__name__, 65 | exception 66 | ) 67 | return False 68 | #+END_SRC 69 | 70 | This code in turns calls the =auth()= method in the =OktaAPIAuth= 71 | class, which does the following: 72 | - Makes an authentication request to Okta, using the =preauth()= method. 73 | - Checks for errors 74 | - Log the user in if the reply was =SUCCESS= 75 | - Deny the user if the reply is =MFA_ENROLL= or 76 | =MFA_ENROLL_ACTIVATE= 77 | 78 | If the response is =MFA_REQUIRED= or =MFA_CHALLENGE= then we do the 79 | following, for each factor that the user has registered: 80 | - Skip the factor if this code doesn't support that factor type. 81 | - Call =doauth()=, the second phase authentication, using the passcode (if we 82 | have one) and the =stateToken=. 83 | - Keep running =doauth()= if the response type is =MFA_CHALLENGE= 84 | or =WAITING=. 85 | - If there response from =doauth()= is =SUCCESS= then log the user 86 | in. 87 | - Fail otherwise. 88 | 89 | When returning errors, we prefer the summary strings in 90 | =errorCauses=, over those in =errorSummary= because the strings in 91 | =errorCauses= tend to be mroe descriptive. For more information, 92 | see the documentation for [[http://developer.okta.com/docs/api/resources/authn.html#verify-security-question-factor][Verify Security Question Factor]]. 93 | 94 | #+NAME: okta-api-auth-auth-method 95 | #+BEGIN_SRC python 96 | try: 97 | rv = self.preauth() 98 | except Exception as s: 99 | log.error('Error connecting to the Okta API: %s', s) 100 | return False 101 | # Check for erros from Okta 102 | if 'errorCauses' in rv: 103 | msg = rv['errorSummary'] 104 | log.info('User %s pre-authentication failed: %s', 105 | self.username, 106 | msg) 107 | return False 108 | elif 'status' in rv: 109 | status = rv['status'] 110 | # Check authentication status from Okta 111 | if status == "SUCCESS": 112 | log.info('User %s authenticated without MFA', self.username) 113 | return True 114 | elif status == "MFA_ENROLL" or status == "MFA_ENROLL_ACTIVATE": 115 | log.info('User %s needs to enroll first', self.username) 116 | return False 117 | elif status == "MFA_REQUIRED" or status == "MFA_CHALLENGE": 118 | log.debug("User %s password validates, checking second factor", 119 | self.username) 120 | res = None 121 | for factor in rv['_embedded']['factors']: 122 | supported_factor_types = ["token:software:totp", "push"] 123 | if factor['factorType'] not in supported_factor_types: 124 | continue 125 | fid = factor['id'] 126 | state_token = rv['stateToken'] 127 | try: 128 | res = self.doauth(fid, state_token) 129 | check_count = 0 130 | fctr_rslt = 'factorResult' 131 | while fctr_rslt in res and res[fctr_rslt] == 'WAITING': 132 | print("Sleeping for {}".format( 133 | self.mfa_push_delay_secs)) 134 | time.sleep(self.mfa_push_delay_secs) 135 | res = self.doauth(fid, state_token) 136 | check_count += 1 137 | if check_count > self.mfa_push_max_retries: 138 | log.info('User %s MFA push timed out' % 139 | self.username) 140 | return False 141 | except Exception as e: 142 | log.error('Unexpected error with the Okta API: %s', e) 143 | return False 144 | if 'status' in res and res['status'] == 'SUCCESS': 145 | log.info("User %s is now authenticated " 146 | "with MFA via Okta API", self.username) 147 | return True 148 | if 'errorCauses' in res: 149 | msg = res['errorCauses'][0]['errorSummary'] 150 | log.debug('User %s MFA token authentication failed: %s', 151 | self.username, 152 | msg) 153 | return False 154 | else: 155 | log.info("User %s is not allowed to authenticate: %s", 156 | self.username, 157 | status) 158 | return False 159 | #+END_SRC 160 | ** Write result to the control file 161 | 162 | *Important:* 163 | The key thing to know about OpenVPN plugins (like this one) are 164 | that they communicate with OpenVPN through a *control 165 | file*. When OpenVPN calls a plugin, it first creates a temporary 166 | file, passes the name of the temporary file to the plugin, then 167 | waits for the temporary file to be written. 168 | 169 | If a "*1*" is written to the file, OpenVPN logs the user in. If a 170 | "*0*" is written to the file, the user is denied. 171 | 172 | Here is what the code does below: 173 | 174 | Because of how critical this control file is, we take the 175 | precaution of checking the permissions on the control file before 176 | writing anything to the file. 177 | 178 | If the user authentication that happened previously was a success, 179 | we write a *1* to the file. Otherwise, we write a *0* to the file, 180 | denying the user by default. 181 | 182 | #+NAME: write-result-to-control-file 183 | #+BEGIN_SRC python 184 | def write_result_to_control_file(self): 185 | self.check_control_file_permissions() 186 | try: 187 | with open(self.control_file, 'w') as f: 188 | if self.user_valid: 189 | f.write('1') 190 | else: 191 | f.write('0') 192 | except IOError: 193 | log.critical("Failed to write to OpenVPN control file '{}'".format( 194 | self.control_file 195 | )) 196 | #+END_SRC 197 | * Learn more 198 | Read the source on GitHub: https://github.com/okta/okta-openvpn 199 | 200 | Key files to read: 201 | - https://github.com/okta/okta-openvpn/blob/master/tests/test_OktaOpenVPNValidator.py 202 | - https://github.com/okta/okta-openvpn/blob/master/okta_openvpn.py 203 | * Files :noexport: 204 | ** okta_openvpn.py 205 | *** Imports 206 | These are the libraries that =okta_openvpn.py= uses. Of note are: 207 | - =cryptography= and =certifi=, which are used for key pinning checks. 208 | - =urllib3= which is used to make requests to the Okta API. 209 | - =okta_pinset= which contains the "keypins" or "fingerprints" used 210 | to verify that we are connecting directly to the Okta API. 211 | #+NAME: imports 212 | #+BEGIN_SRC python 213 | import ConfigParser 214 | from ConfigParser import MissingSectionHeaderError 215 | import base64 216 | import hashlib 217 | import json 218 | import logging 219 | import logging.handlers 220 | import os 221 | import platform 222 | import stat 223 | import sys 224 | import time 225 | import urlparse 226 | 227 | from cryptography import x509 228 | from cryptography.hazmat.backends import default_backend 229 | from cryptography.hazmat.primitives import serialization 230 | import certifi 231 | import urllib3 232 | 233 | from okta_pinset import okta_pinset 234 | #+END_SRC 235 | *** The User Agent 236 | This defines the User Agent for =okta_openvpn.py=. Here is an 237 | example of what a User Agent would look like: 238 | 239 | #+BEGIN_EXAMPLE 240 | OktaOpenVPN/0.10.0 (Darwin 12.4.0) CPython/2.7.5 241 | #+END_EXAMPLE 242 | 243 | This User Agent has three parts: 244 | 1. The name and version of the software. 245 | 2. The Operating System name and version. 246 | 247 | In this case "Darwin" refers to the open-source Unix operating 248 | system from Apple. Version 12.4.0 of Darwin corrisponds with OS 249 | X Mountain Lion 10.8.4. 250 | 251 | 3. The programming language interepreter and version. 252 | 253 | In this case, "CPython" refers to the reference implementation 254 | of Python, which is written in C 255 | 256 | #+NAME: setup-useragent 257 | #+BEGIN_SRC python 258 | version = "<>" 259 | # OktaOpenVPN/0.10.0 (Darwin 12.4.0) CPython/2.7.5 260 | user_agent = ("OktaOpenVPN/{version} " 261 | "({system} {system_version}) " 262 | "{implementation}/{python_version}").format( 263 | version=version, 264 | system=platform.uname()[0], 265 | system_version=platform.uname()[2], 266 | implementation=platform.python_implementation(), 267 | python_version=platform.python_version()) 268 | #+END_SRC 269 | 270 | *** Logging 271 | This sets up logging, by default we send everything 272 | (=logging.DEBUG=) to syslog. Also included is commented out code to 273 | also log to STDERR and/or logging to a file 274 | (=/tmp/okta_openvpn.log= by default) 275 | 276 | #+NAME: setup-logging 277 | #+BEGIN_SRC python 278 | log = logging.getLogger('okta_openvpn') 279 | log.setLevel(logging.DEBUG) 280 | syslog = logging.handlers.SysLogHandler() 281 | syslog_fmt = "%(module)s-%(processName)s[%(process)d]: %(name)s: %(message)s" 282 | syslog.setFormatter(logging.Formatter(syslog_fmt)) 283 | log.addHandler(syslog) 284 | # # Uncomment to enable logging to STDERR 285 | # errlog = logging.StreamHandler() 286 | # errlog.setFormatter(logging.Formatter(syslog_fmt)) 287 | # log.addHandler(errlog) 288 | # # Uncomment to enable logging to a file 289 | # filelog = logging.FileHandler('/tmp/okta_openvpn.log') 290 | # filelog.setFormatter(logging.Formatter(syslog_fmt)) 291 | # log.addHandler(filelog) 292 | #+END_SRC 293 | 294 | *** Key Pinning 295 | These are custom exceptions that we use throw for error conditions 296 | that are unique to this script. 297 | 298 | =PinError= is used when the pin for the public key of the remote 299 | TLS certificate isn't found in our set of valid pins. 300 | 301 | =ControlFilePermissionsError= is used when we encounter a 302 | permissions error related to the control file used to communicate 303 | success/failure of an authentication to OpenVPN. 304 | 305 | #+NAME: custom-exceptions 306 | #+BEGIN_SRC python 307 | class PinError(Exception): 308 | "Raised when a pin isn't found in a certificate" 309 | pass 310 | 311 | 312 | class ControlFilePermissionsError(Exception): 313 | "Raised when the control file or containing directory have bad permissions" 314 | pass 315 | #+END_SRC 316 | 317 | This code is used to implement HPKP-style key pinning with the Okta 318 | API. The code works by extending the =urllib3.HTTPSConnectionPool= 319 | object, implementing the =_validate_conn= which is run to validate 320 | connections. 321 | 322 | Essentially, this code hashes the public key of the remote TLS 323 | certificate and compares the hash against a whitelist of hashes. 324 | 325 | #+NAME: publickey-pinset-connectionpool 326 | #+BEGIN_SRC python 327 | class PublicKeyPinsetConnectionPool(urllib3.HTTPSConnectionPool): 328 | def __init__(self, *args, **kwargs): 329 | self.pinset = kwargs.pop('assert_pinset', None) 330 | super(PublicKeyPinsetConnectionPool, self).__init__(*args, **kwargs) 331 | 332 | def _validate_conn(self, conn): 333 | super(PublicKeyPinsetConnectionPool, self)._validate_conn(conn) 334 | if not conn.is_verified: 335 | raise Exception("Unexpected verification error.") 336 | 337 | cert = conn.sock.getpeercert(binary_form=True) 338 | public_key = x509.load_der_x509_certificate( 339 | cert, 340 | default_backend()).public_key() 341 | public_key_raw = public_key.public_bytes( 342 | serialization.Encoding.DER, 343 | serialization.PublicFormat.SubjectPublicKeyInfo) 344 | public_key_sha256 = hashlib.sha256(public_key_raw).digest() 345 | public_key_sha256_base64 = base64.b64encode(public_key_sha256) 346 | 347 | if public_key_sha256_base64 not in self.pinset: 348 | pin_failure_message = ( 349 | 'Refusing to authenticate ' 350 | 'because host {remote_host} failed ' 351 | 'a TLS public key pinning check. ' 352 | 'Please contact support@okta.com with this error message' 353 | ).format(remote_host=conn.host) 354 | log.critical(pin_failure_message) 355 | raise PinError("Public Key not found in pinset!") 356 | #+END_SRC 357 | 358 | *** class OktaAPIAuth (object) 359 | This code that communicates with the Okta API. 360 | #+NAME: okta-api-auth 361 | #+BEGIN_SRC python 362 | class OktaAPIAuth(object): 363 | def __init__(self, okta_url, okta_token, 364 | username, password, client_ipaddr, 365 | mfa_push_delay_secs=None, 366 | mfa_push_max_retries=None, 367 | assert_pinset=None): 368 | passcode_len = 6 369 | self.okta_url = None 370 | self.okta_token = okta_token 371 | self.username = username 372 | self.password = password 373 | self.client_ipaddr = client_ipaddr 374 | self.passcode = None 375 | self.okta_urlparse = urlparse.urlparse(okta_url) 376 | self.mfa_push_delay_secs = mfa_push_delay_secs 377 | self.mfa_push_max_retries = mfa_push_max_retries 378 | if assert_pinset is None: 379 | assert_pinset = okta_pinset 380 | url_new = (self.okta_urlparse.scheme, 381 | self.okta_urlparse.netloc, 382 | '', '', '', '') 383 | self.okta_url = urlparse.urlunparse(url_new) 384 | if password and len(password) > passcode_len: 385 | last = password[-passcode_len:] 386 | if last.isdigit(): 387 | self.passcode = last 388 | self.password = password[:-passcode_len] 389 | self.pool = PublicKeyPinsetConnectionPool( 390 | self.okta_urlparse.hostname, 391 | self.okta_urlparse.port, 392 | assert_pinset=assert_pinset, 393 | cert_reqs='CERT_REQUIRED', 394 | ca_certs=certifi.where(), 395 | ) 396 | 397 | def okta_req(self, path, data): 398 | ssws = "SSWS {token}".format(token=self.okta_token) 399 | headers = { 400 | 'user-agent': user_agent, 401 | 'content-type': 'application/json', 402 | 'accept': 'application/json', 403 | 'authorization': ssws, 404 | } 405 | url = "{base}/api/v1{path}".format(base=self.okta_url, path=path) 406 | req = self.pool.urlopen( 407 | 'POST', 408 | url, 409 | headers=headers, 410 | body=json.dumps(data) 411 | ) 412 | return json.loads(req.data) 413 | 414 | def preauth(self): 415 | path = "/authn" 416 | data = { 417 | 'username': self.username, 418 | 'password': self.password, 419 | } 420 | return self.okta_req(path, data) 421 | 422 | def doauth(self, fid, state_token): 423 | path = "/authn/factors/{fid}/verify".format(fid=fid) 424 | data = { 425 | 'fid': fid, 426 | 'stateToken': state_token, 427 | 'passCode': self.passcode, 428 | } 429 | return self.okta_req(path, data) 430 | 431 | def auth(self): 432 | username = self.username 433 | password = self.password 434 | status = False 435 | rv = False 436 | 437 | invalid_username_or_password = ( 438 | username is None or 439 | username == '' or 440 | password is None or 441 | password == '') 442 | if invalid_username_or_password: 443 | log.info("Missing username or password for user: %s (%s) - " 444 | "Reported username may be 'None' due to this", 445 | username, 446 | self.client_ipaddr) 447 | return False 448 | 449 | if not self.passcode: 450 | log.info("No second factor found for username %s", username) 451 | 452 | log.debug("Authenticating username %s", username) 453 | <> 454 | #+END_SRC 455 | *** class OktaOpenVPNValidator(object) 456 | 457 | In short, this class gets the "environment" set up for the 458 | OktaAPIAuth class. It reads in configuration files and environment 459 | variables, makes sure that permissions are correct on the "Control 460 | File", calls OktaAPIAuth and writes to the Control File as 461 | approprate. 462 | 463 | #+NAME: okta-openvpn-validator 464 | #+BEGIN_SRC python 465 | class OktaOpenVPNValidator(object): 466 | def __init__(self): 467 | self.cls = OktaAPIAuth 468 | self.username_trusted = False 469 | self.user_valid = False 470 | self.control_file = None 471 | self.site_config = {} 472 | self.config_file = None 473 | self.env = os.environ 474 | self.okta_config = {} 475 | self.username_suffix = None 476 | self.always_trust_username = False 477 | # These can be modified in the 'okta_openvpn.ini' file. 478 | # By default, we retry for 2 minutes: 479 | self.mfa_push_max_retries = "20" 480 | self.mfa_push_delay_secs = "3" 481 | 482 | def read_configuration_file(self): 483 | cfg_path_defaults = [ 484 | '/etc/openvpn/okta_openvpn.ini', 485 | '/etc/okta_openvpn.ini', 486 | 'okta_openvpn.ini'] 487 | cfg_path = cfg_path_defaults 488 | parser_defaults = { 489 | 'AllowUntrustedUsers': self.always_trust_username, 490 | 'UsernameSuffix': self.username_suffix, 491 | 'MFAPushMaxRetries': self.mfa_push_max_retries, 492 | 'MFAPushDelaySeconds': self.mfa_push_delay_secs, 493 | } 494 | if self.config_file: 495 | cfg_path = [] 496 | cfg_path.append(self.config_file) 497 | log.debug(cfg_path) 498 | for cfg_file in cfg_path: 499 | if os.path.isfile(cfg_file): 500 | try: 501 | cfg = ConfigParser.ConfigParser(defaults=parser_defaults) 502 | cfg.read(cfg_file) 503 | self.site_config = { 504 | 'okta_url': cfg.get('OktaAPI', 'Url'), 505 | 'okta_token': cfg.get('OktaAPI', 'Token'), 506 | 'mfa_push_max_retries': cfg.get('OktaAPI', 507 | 'MFAPushMaxRetries'), 508 | 'mfa_push_delay_secs': cfg.get('OktaAPI', 509 | 'MFAPushDelaySeconds'), 510 | } 511 | always_trust_username = cfg.get( 512 | 'OktaAPI', 513 | 'AllowUntrustedUsers') 514 | if always_trust_username == 'True': 515 | self.always_trust_username = True 516 | self.username_suffix = cfg.get('OktaAPI', 'UsernameSuffix') 517 | return True 518 | except MissingSectionHeaderError as e: 519 | log.debug(e) 520 | if 'okta_url' not in self.site_config and \ 521 | 'okta_token' not in self.site_config: 522 | log.critical("Failed to load config") 523 | return False 524 | 525 | def load_environment_variables(self): 526 | if 'okta_url' not in self.site_config: 527 | log.critical('OKTA_URL not defined in configuration') 528 | return False 529 | if 'okta_token' not in self.site_config: 530 | log.critical('OKTA_TOKEN not defined in configuration') 531 | return False 532 | # Taken from a validated VPN client-side SSL certificate 533 | username = self.env.get('common_name') 534 | password = self.env.get('password') 535 | client_ipaddr = self.env.get('untrusted_ip', '0.0.0.0') 536 | # Note: 537 | # username_trusted is True if the username comes from a certificate 538 | # 539 | # Meaning, if self.common_name is NOT set, but self.username IS, 540 | # then self.username_trusted will be False 541 | if username is not None: 542 | self.username_trusted = True 543 | else: 544 | # This is set according to what the VPN client has sent us 545 | username = self.env.get('username') 546 | if self.always_trust_username: 547 | self.username_trusted = self.always_trust_username 548 | if self.username_suffix and '@' not in username: 549 | username = username + '@' + self.username_suffix 550 | self.control_file = self.env.get('auth_control_file') 551 | if self.control_file is None: 552 | log.info(("No control file found, " 553 | "if using a deferred plugin " 554 | "authentication will stall and fail.")) 555 | self.okta_config = { 556 | 'okta_url': self.site_config['okta_url'], 557 | 'okta_token': self.site_config['okta_token'], 558 | 'username': username, 559 | 'password': password, 560 | 'client_ipaddr': client_ipaddr, 561 | } 562 | for item in ['mfa_push_max_retries', 'mfa_push_delay_secs']: 563 | if item in self.site_config: 564 | self.okta_config[item] = self.site_config[item] 565 | assert_pin = self.env.get('assert_pin') 566 | if assert_pin: 567 | self.okta_config['assert_pinset'] = [assert_pin] 568 | 569 | <> 570 | 571 | def check_control_file_permissions(self): 572 | file_mode = os.stat(self.control_file).st_mode 573 | if file_mode & stat.S_IWGRP or file_mode & stat.S_IWOTH: 574 | log.critical( 575 | 'Refusing to authenticate. The file %s' 576 | ' must not be writable by non-owners.', 577 | self.control_file 578 | ) 579 | raise ControlFilePermissionsError() 580 | dir_name = os.path.split(self.control_file)[0] 581 | dir_mode = os.stat(dir_name).st_mode 582 | if dir_mode & stat.S_IWGRP or dir_mode & stat.S_IWOTH: 583 | log.critical( 584 | 'Refusing to authenticate.' 585 | ' The directory containing the file %s' 586 | ' must not be writable by non-owners.', 587 | self.control_file 588 | ) 589 | raise ControlFilePermissionsError() 590 | 591 | <> 592 | 593 | <> 594 | #+END_SRC 595 | 596 | #+NAME: oktaopenvpnvalidator-run 597 | #+BEGIN_SRC python 598 | def run(self): 599 | self.read_configuration_file() 600 | self.load_environment_variables() 601 | self.authenticate() 602 | self.write_result_to_control_file() 603 | #+END_SRC 604 | 605 | *** Running from the command line 606 | If the user is valid, we exit with "0". If the user is not valid, 607 | we exit with "1". This was split out into a seperate function to 608 | avoid confusion seeing =sys.exit(0)= in the code. 609 | #+NAME: return-error-code-for 610 | #+BEGIN_SRC python 611 | def return_error_code_for(validator): 612 | if validator.user_valid: 613 | sys.exit(0) 614 | else: 615 | sys.exit(1) 616 | #+END_SRC 617 | 618 | Checking if =__name__= equals ="__main__"= is the Pythonic way of 619 | detecting if this code has been called from the command line (as 620 | opposed to being included via an =import= statement). 621 | 622 | #+NAME: main-loop 623 | #+BEGIN_SRC python 624 | # This is tested by test_command.sh via tests/test_command.py 625 | if __name__ == "__main__": # pragma: no cover 626 | validator = OktaOpenVPNValidator() 627 | validator.run() 628 | return_error_code_for(validator) 629 | #+END_SRC 630 | *** okta_openvpn.py 631 | #+BEGIN_SRC python :tangle okta_openvpn.py :noweb yes 632 | #!/usr/bin/env python2 633 | # vim: set noexpandtab:ts=4 634 | 635 | # This Source Code Form is subject to the terms of the Mozilla Public 636 | # License, v. 2.0. If a copy of the MPL was not distributed with this 637 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 638 | # Contributors: gdestuynder@mozilla.com 639 | 640 | <> 641 | 642 | <> 643 | <> 644 | 645 | 646 | <> 647 | 648 | 649 | <> 650 | 651 | 652 | <> 653 | 654 | 655 | <> 656 | 657 | 658 | <> 659 | 660 | <> 661 | #+END_SRC 662 | ** okta_pinset.py 663 | Below are a list of "Pins" (or fingerprints) for the /public keys/ 664 | that Okta uses, or will use, in TLS certificates. 665 | 666 | There are total of 16 pins below, 4 pins per domain, for two public 667 | domains and two private domains that Okta uses for testing. 668 | 669 | Here is how to generate a "pin" using =openssl= command line 670 | utilities: 671 | 672 | #+NAME: create-pins 673 | #+BEGIN_SRC sh :noweb yes 674 | <> | 675 | <> | 676 | <> | 677 | <> 678 | #+END_SRC 679 | 680 | Here is what this command does, line by line: 681 | 682 | #+NAME: fetch-tls-certificate 683 | #+BEGIN_SRC sh 684 | echo -n | openssl s_client -connect example.com:443 685 | #+END_SRC 686 | This fetches a TLS certificate from a server, printing the X.509 687 | formatted certificate on STDOUT. =echo -n= is needed because 688 | =s_client= expects something on STDIN. 689 | 690 | #+NAME: extract-public-key 691 | #+BEGIN_SRC sh 692 | openssl x509 -noout -pubkey 693 | #+END_SRC 694 | 695 | This takes an X.509 certificate on STDIN and prints a PEM formatted 696 | public key on STDOUT. 697 | 698 | #+NAME: convert-public-key-to-der 699 | #+BEGIN_SRC sh 700 | openssl rsa -pubin -outform der 701 | #+END_SRC 702 | 703 | This takes a PEM encoded public key on STDIN (=-pubin=) and 704 | prints the DER formatted key on STDOUT. 705 | 706 | #+NAME: create-sha256-base64-hash 707 | #+BEGIN_SRC sh 708 | openssl dgst -sha256 -binary | base64 709 | #+END_SRC 710 | 711 | This makes a SHA-256 hash of STDIN, which is then converted to the 712 | Base64 encoding scheme. 713 | 714 | 715 | #+BEGIN_SRC python :tangle okta_pinset.py :noweb yes 716 | # # Here is how a pin like those below may be generated: 717 | # <> 718 | okta_pinset = [ 719 | # okta.com 720 | 'r5EfzZxQVvQpKo3AgYRaT7X2bDO/kj3ACwmxfdT2zt8=', 721 | 'MaqlcUgk2mvY/RFSGeSwBRkI+rZ6/dxe/DuQfBT/vnQ=', 722 | '72G5IEvDEWn+EThf3qjR7/bQSWaS2ZSLqolhnO6iyJI=', 723 | 'rrV6CLCCvqnk89gWibYT0JO6fNQ8cCit7GGoiVTjCOg=', 724 | # oktapreview.com 725 | 'jZomPEBSDXoipA9un78hKRIeN/+U4ZteRaiX8YpWfqc=', 726 | 'axSbM6RQ+19oXxudaOTdwXJbSr6f7AahxbDHFy3p8s8=', 727 | 'SE4qe2vdD9tAegPwO79rMnZyhHvqj3i5g1c2HkyGUNE=', 728 | 'ylP0lMLMvBaiHn0ihLxHjzvlPVQNoyQ+rMiaj0da/Pw=', 729 | # internal testing 730 | 'W2qOJ9F9eo3CYHzL5ZIjYEizINI1cUPEb7yD45ihTXg=', 731 | 'PJ1QGTlW5ViFNhswMsYKp4X8C7KdG8nDW4ZcXLmYMyI=', 732 | '5LlRWGTBVjpfNXXU5T7cYVUbOSPcgpMgdjaWd/R9Leg=', 733 | 'lpaMLlEsp7/dVZoeWt3f9ciJIMGimixAIaKNsn9/bCY=', 734 | # internal testing 735 | 'Uit61pzomPOIy0svL1z4OUx3FMBr9UWQVdyG7ZlSLK8=', 736 | 'Ul2vkypIA80/JDebYsXq8FGdtmtrx5WJAAHDlSwWOes=', 737 | 'rx1UuNLIkJs53Jd60G/zY947XcDIf56JyM/yFJyR/GE=', 738 | 'VvpiE4cl60BvOU8X4AfkWeUPsmRUSh/nVbJ2rnGDZHI=', 739 | ] 740 | 741 | #+END_SRC 742 | ** tests 743 | *** shared/__init__.py 744 | Here is how to debug using a local version of the mock server, via 745 | ngrok: 746 | #+BEGIN_EXAMPLE 747 | self.example_dot_com_pin = ( 748 | 'wiviOfSDwIlXvBBiGcwtOsGjCN+73Qo2Xxe5NRI0zwA=') 749 | self.herokuapp_dot_com_pin = ( 750 | - '2hLOYtjSs5a3Jxy5GVM5EMuqa3JHhR6gM99EoaDauug=') 751 | + 'zyLK9e1SySrnnTDsqXISq1MppH4OvOcJRM9eh0Rm8AA=') 752 | + # '2hLOYtjSs5a3Jxy5GVM5EMuqa3JHhR6gM99EoaDauug=') 753 | self.okta_url = os.environ.get( 754 | 'okta_url_mock', 755 | - 'https://mocked-okta-api.herokuapp.com') 756 | + 'https://0414e2d8.ngrok.io') 757 | + # 'https://mocked-okta-api.herokuapp.com') 758 | self.okta_token = 'mocked-token-for-openvpn' 759 | self.username_prefix = 'user_MFA_REQUIRED' 760 | self.username_suffix = 'example.com' 761 | #+END_EXAMPLE 762 | 763 | #+BEGIN_EXAMPLE 764 | --- a/tests/test_ssl_public_key_pinning.py 765 | +++ b/tests/test_ssl_public_key_pinning.py 766 | @@ -80,7 +80,7 @@ class TestOktaAPIAuthTLSPinning(OktaTestCase): 767 | last_error = self.okta_log_messages['critical'][-1:][0] 768 | messages = [ 769 | 'efusing to authenticate', 770 | - 'mocked-okta-api.herokuapp.com', 771 | + # 'mocked-okta-api.herokuapp.com', 772 | 'TLS public key pinning check', 773 | 'lease contact support@okta.com', 774 | ] 775 | #+END_EXAMPLE 776 | --------------------------------------------------------------------------------