def test_preauth_01(self): """ preauth that can't work returns None """ self.main_object.duo_client_args['host'] = 'badhost' tmplibrary = DuoAPIAuth(**self.main_object.duo_client_args) tmplibrary.load_user_to_verify(self.user_data['push']) res = tmplibrary._preauth() self.assertIsNone(res, '_preauth on a damaged server returns None')
def test_preflight_01(self): """ preflight fails for bad host """ self.main_object.duo_client_args['host'] = 'badhost' tmplibrary = DuoAPIAuth(**self.main_object.duo_client_args) # No user data needs to be loaded to be preflighted res = tmplibrary._preflight() self.assertFalse(res, '_preflight must be False for a bad host')
def test_preflight_02(self): """ preflight fails for bad skey """ if not self.deep_test_rawauth: # pragma: no cover return self.skipTest('because of .deep_testing preference') self.main_object.duo_client_args['skey'] = 'wrong-passcode' tmplibrary = DuoAPIAuth(**self.main_object.duo_client_args) # No user data needs to be loaded to be preflighted res = tmplibrary._preflight() self.assertFalse(res, '_preflight must be False for a bad skey')
def test_log_good(self): """ Test sending a log message - all good """ # There is no raise or return. We're just poking at # the function and making sure it doesn't raise. tmplibrary = DuoAPIAuth(log_func=self.main_object.log, **self.main_object.duo_client_args) tmplibrary.log( summary='TEST message', severity='DEBUG', )
def test_main_01(self): """ main_auth with no connection goes fail_open """ self.main_object.duo_client_args['host'] = 'badhost' for state in (True, False): tmplibrary = DuoAPIAuth(fail_open=state, **self.main_object.duo_client_args) tmplibrary.load_user_to_verify(self.user_data['auto']) res = tmplibrary.main_auth() self.assertEqual( res, state, 'main_auth with no connection must return ' 'the fail_open state')
def setUp(self): """ Preparing test rig """ # To get a decent test, we're going to need items from the config # file in order to test. with mock.patch.object(DuoOpenVPN, 'CONFIG_FILE_LOCATIONS', new=[ 'duo_openvpn.conf', '/usr/local/etc/duo_openvpn.conf', '/etc/openvpn/duo_openvpn.conf', '/etc/duo_openvpn.conf' ]): self.main_object = DuoOpenVPN() try: self.normal_user = self.main_object.configfile.get( 'testing', 'normal_user') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover return self.skipTest('No testing/normal_user defined') try: self.deep_test_rawauth = self.main_object.configfile.getboolean( 'testing', 'deep_testing_rawauth') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover self.deep_test_rawauth = False try: self.deep_test_mfa = self.main_object.configfile.getboolean( 'testing', 'deep_testing_mfa') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover self.deep_test_mfa = False try: self.deep_test_main = self.main_object.configfile.getboolean( 'testing', 'deep_testing_mainauth') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover self.deep_test_main = False # os.environ['untrusted_ip'] = 'testing-ip-Unknown-is-OK' os.environ['common_name'] = self.normal_user user_creds = dict() for varname in OpenVPNCredentials.DUO_RESERVED_WORDS: os.environ['password'] = varname res = OpenVPNCredentials() res.load_variables_from_environment() user_creds[varname] = res self.user_data = user_creds # self.library = DuoAPIAuth(**self.main_object.duo_client_args)
def test_fail_open_02(self): """ _fail_open performs as expected on a False """ tmplibrary = DuoAPIAuth(fail_open=False, **self.main_object.duo_client_args) res = tmplibrary._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertFalse(res, '_fail_open must return an expected False')
def setUp(self): """ Preparing test rig """ # To get a decent test, we're going to need items from the config # file in order to test. config = configparser.ConfigParser() config.add_section('duo-credentials') config.set('duo-credentials', 'IKEY', 'DI9QQ99X9MK4H99RJ9FF') config.set('duo-credentials', 'SKEY', '2md9rw5xeyxt8c648dgkmdrg3zpvnhj5b596mgku') config.set('duo-credentials', 'HOST', 'api-9f134ff9.duosekurity.com') with open(self.testing_conffile, 'w') as configfile: config.write(configfile) with mock.patch.object(DuoOpenVPN, 'CONFIG_FILE_LOCATIONS', new=[ 'duo_openvpn.conf', '/usr/local/etc/duo_openvpn.conf', '/etc/openvpn/duo_openvpn.conf', '/etc/duo_openvpn.conf', self.testing_conffile ]): self.main_object = DuoOpenVPN() os.environ['untrusted_ip'] = 'testing-ip-Unknown-is-OK' os.environ['common_name'] = 'bob' user_creds = dict() for varname in OpenVPNCredentials.DUO_RESERVED_WORDS: os.environ['password'] = varname res = OpenVPNCredentials() res.load_variables_from_environment() user_creds[varname] = res self.user_data = user_creds # with mock.patch.object(DuoOpenVPN, 'CONFIG_FILE_LOCATIONS', new=[ 'duo_openvpn.conf', '/usr/local/etc/duo_openvpn.conf', '/etc/openvpn/duo_openvpn.conf', '/etc/duo_openvpn.conf', self.testing_conffile ]): self.library = DuoAPIAuth(**self.main_object.duo_client_args)
def main_authentication(self): # pylint: disable=too-many-return-statements """ The main authentication function. Return True if the user successfully authenticated. Return False if they didn't. It's expected that we'll handle errors within this and not raise. """ # Set up a user object based on the environmental variables. user_data = OpenVPNCredentials() try: user_data.load_variables_from_environment() except ValueError: # pragma: no cover # This happens when we have a total mismatch, like, openvpn # didn't send valid environmental variables to the plugin, # or someone got here without a certificate(!?!) self.log( summary='FAIL: VPN environmental load, software bug', severity='ERROR', details={ 'error': 'true', 'success': 'false', }, ) traceback.print_exc() return False username = user_data.username client_ipaddr = user_data.client_ipaddr password = user_data.password try: iam_searcher = iamvpnlibrary.IAMVPNLibrary() except RuntimeError: # pragma: no cover # Couldn't connect to the IAM service: self.log( summary=('FAIL: Unable to connect to IAM'), severity='INFO', details={ 'username': username, 'sourceipaddress': client_ipaddr, 'success': 'false', }, ) return False if not iam_searcher.user_allowed_to_vpn(username): # Here we have a user not allowed to VPN in at all. # This is some form of "their account is disabled" and/or # they aren't in the approved ACL list. self.log( summary=('FAIL: VPN user "{}" administratively ' 'denied'.format(username)), severity='INFO', details={ 'username': username, 'sourceipaddress': client_ipaddr, 'success': 'false', }, ) return False if not iam_searcher.does_user_require_vpn_mfa(username): # We've hit a special snowflake user who does not require MFA. if password is None: # If a password of 'None' makes it through, auth will fail. # 'None' can happen if someone tries using a Duo keyword as a # password. In the unbelievably small chance that password # is correct, it's an unacceptably bad password. Punt them. # I mean, really, you've got a non-MFA user, who then has no # password / a horrible one. That's zero factors. allow_1fa = False else: allow_1fa = iam_searcher.non_mfa_vpn_authentication( username, password) if allow_1fa: summary = 'SUCCESS: non-MFA user "{}" accepted by password' else: summary = 'FAIL: non-MFA user "{}" denied by password' self.log( summary=summary.format(username), severity='INFO', details={ 'username': username, 'sourceipaddress': client_ipaddr, 'success': str(allow_1fa).lower(), }, ) return allow_1fa # We don't establish a Duo object until we need it. duo = DuoAPIAuth(fail_open=self.failopen, log_func=self.log, **self.duo_client_args) if not duo.load_user_to_verify( user_config=user_data): # pragma: no cover # The load_user_to_verify method is benign, so we should # never fail to load, but if we do it'll be hard to find, # so this 'if' block captures an edge case we've never seen, # just in case. self.log( summary='FAIL: VPN user failed MFA pre-load', severity='INFO', details={ 'username': username, 'sourceipaddress': client_ipaddr, 'success': 'false', }, ) return False try: return duo.main_auth() except Exception: # pragma: no cover pylint: disable=broad-except # Deliberately catch all errors until we can find what can # go wrong. self.log( summary='FAIL: VPN User auth failed, software bug', severity='ERROR', details={ 'username': username, 'error': 'true', 'sourceipaddress': client_ipaddr, 'success': 'false', }, ) traceback.print_exc() return False # We should never get here. self.log( summary='FAIL: VPN User fell through all possible ' 'Duo checks, software bug', severity='ERROR', details={ 'username': username, 'error': 'true', 'success': 'false', }, ) # pragma: no cover return False # pragma: no cover
class TestDuoAPIAuthUnit(unittest.TestCase): """ These are intended to exercise internal functions of the library's DuoAPIAuth class without going out to Duo. """ testing_conffile = '/tmp/TestDuoAPIAuthUnit.txt' def setUp(self): """ Preparing test rig """ # To get a decent test, we're going to need items from the config # file in order to test. config = configparser.ConfigParser() config.add_section('duo-credentials') config.set('duo-credentials', 'IKEY', 'DI9QQ99X9MK4H99RJ9FF') config.set('duo-credentials', 'SKEY', '2md9rw5xeyxt8c648dgkmdrg3zpvnhj5b596mgku') config.set('duo-credentials', 'HOST', 'api-9f134ff9.duosekurity.com') with open(self.testing_conffile, 'w') as configfile: config.write(configfile) with mock.patch.object(DuoOpenVPN, 'CONFIG_FILE_LOCATIONS', new=[ 'duo_openvpn.conf', '/usr/local/etc/duo_openvpn.conf', '/etc/openvpn/duo_openvpn.conf', '/etc/duo_openvpn.conf', self.testing_conffile ]): self.main_object = DuoOpenVPN() os.environ['untrusted_ip'] = 'testing-ip-Unknown-is-OK' os.environ['common_name'] = 'bob' user_creds = dict() for varname in OpenVPNCredentials.DUO_RESERVED_WORDS: os.environ['password'] = varname res = OpenVPNCredentials() res.load_variables_from_environment() user_creds[varname] = res self.user_data = user_creds # with mock.patch.object(DuoOpenVPN, 'CONFIG_FILE_LOCATIONS', new=[ 'duo_openvpn.conf', '/usr/local/etc/duo_openvpn.conf', '/etc/openvpn/duo_openvpn.conf', '/etc/duo_openvpn.conf', self.testing_conffile ]): self.library = DuoAPIAuth(**self.main_object.duo_client_args) def tearDown(self): """ Clear the env so we don't impact other tests """ for varname in ['common_name', 'password', 'username', 'untrusted_ip']: if varname in os.environ: del os.environ[varname] try: os.unlink(self.testing_conffile) except OSError: # pragma: no cover # ... else, there was nothing there (likely) ... if os.path.exists(self.testing_conffile): # ... but if there is, we couldn't delete it, so complain. raise def test_init(self): """ Verify init does the right thing """ self.assertIsInstance( self.library, duo_client.Auth, 'our DuoAPIAuth must be a descendant ' 'of duo_client.Auth') self.assertIsInstance(self.library.hostname, str, 'hostname must be set and a string') self.assertIsInstance(self.library._fail_open, bool, '_fail_open must return a bool') self.assertIsNone(self.library.user_config, 'user_config must be empty on a plain init') self.assertIsNone(self.library.log_func, 'log_func must default to None') def test_fail_open_00(self): """ _fail_open performs as expected """ res = self.library._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertFalse(res, '_fail_open defaults to False') def test_fail_open_01(self): """ _fail_open performs as expected on a True """ tmplibrary = DuoAPIAuth(fail_open=True, **self.main_object.duo_client_args) res = tmplibrary._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertTrue(res, '_fail_open must return an expected True') def test_fail_open_02(self): """ _fail_open performs as expected on a False """ tmplibrary = DuoAPIAuth(fail_open=False, **self.main_object.duo_client_args) res = tmplibrary._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertFalse(res, '_fail_open must return an expected False') def test_load_user_to_verify_00(self): """ load_user_to_verify can fail for garbage """ res = self.library.load_user_to_verify({}) self.assertFalse(res, 'load_user_to_verify must be False ' 'for junk input') def test_load_user_to_verify_01(self): """ load_user_to_verify can fail for a bad username """ setattr(self.user_data['push'], 'username', 0) res = self.library.load_user_to_verify(self.user_data['push']) self.assertFalse( res, 'load_user_to_verify must be False ' 'for a bad username') def test_load_user_to_verify_02(self): """ load_user_to_verify can fail for a bad factor """ setattr(self.user_data['push'], 'factor', 0) res = self.library.load_user_to_verify(self.user_data['push']) self.assertFalse( res, 'load_user_to_verify must be False ' 'for a bad factor') def test_load_user_to_verify_03(self): """ load_user_to_verify can succeed and return True """ res = self.library.load_user_to_verify(self.user_data['push']) self.assertTrue(res, 'load_user_to_verify must be True') def test_log(self): """ Test the weird logging function """ with mock.patch.object(self.library, 'log_func') as mock_dummy: self.library.log('foo', bar='quux') mock_dummy.assert_called_once_with('foo', bar='quux') def test_10_preflight(self): """ Test various levels of preflight checks """ with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'ping', side_effect=socket.error): res = self.library._preflight() self.assertFalse(res, "Broken ping must return False") # Check the call_args - [1] is the kwargs. #self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') #self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'ping', return_value=None): with mock.patch.object(self.library, 'check', side_effect=socket.error): res = self.library._preflight() self.assertFalse(res, "Broken check must return False") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'ping', return_value=None): with mock.patch.object(self.library, 'check', side_effect=RuntimeError): res = self.library._preflight() self.assertFalse(res, "Broken check must return False") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'ping', return_value=None): with mock.patch.object(self.library, 'check', return_value=None): res = self.library._preflight() self.assertTrue(res, "Good preflight check must return True") def test_20_preauth(self): """ Test various levels of preauth checks """ # a _preauth without users configured must fail hard: with self.assertRaises(Exception): self.library._preauth() self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'preauth', side_effect=socket.error): res = self.library._preauth() self.assertFalse(res, "Broken check must return False") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'preauth', side_effect=RuntimeError): res = self.library._preauth() self.assertFalse(res, "Broken check must return False") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'preauth', side_effect=httplib.BadStatusLine('')): res = self.library._preauth() self.assertFalse(res, "Broken check must return False") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') upstream_return = ['some', 14, 'thing'] with mock.patch.object(self.library, 'preauth', return_value=upstream_return): res = self.library._preauth() self.assertEqual(res, upstream_return, "Good preauth returns whatever upstream sent") def test_30_auth_fails(self): """ Test various failure levels of auth checks """ # a _auth without users configured must fail hard: with self.assertRaises(Exception): self.library._auth() # SMS can't auth: with mock.patch.object(self.library, 'log') as mock_log: self.library.user_config = self.user_data['sms'] res = self.library._auth() self.assertIsNone(res, "sms _auth must return None") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') # garbage factors can't auth: with mock.patch.object(self.library, 'log') as mock_log: self.library.user_config = self.user_data['push'] self.library.user_config.factor = 'nonsense' res = self.library._auth() self.assertIsNone(res, "nonsense factor _auth must return None") # Check the call_args - [1] is the kwargs. self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') def test_32_auth_phone(self): """ Test phone on auth checks. """ # Remember that 'phone' is a garbage code path. If you break here, # feel free to attack this problem some other way. self.library.user_config = self.user_data['phone'] with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=socket.error): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=RuntimeError): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') upstream_return = ['some', 32, 'thing'] with mock.patch.object(self.library, 'auth', return_value=upstream_return) as mock_auth: res = self.library._auth() self.assertEqual(res, upstream_return, "Good auth returns whatever upstream sent") self.assertEqual(mock_auth.call_args[1]['device'], 'auto') def test_33_auth_auto(self): """ Test auto on auth checks. """ # Remember that 'auto' is a garbage code path. If you break here, # feel free to attack this problem some other way. self.library.user_config = self.user_data['auto'] with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=socket.error): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=RuntimeError): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') upstream_return = ['some', 33, 'thing'] with mock.patch.object(self.library, 'auth', return_value=upstream_return) as mock_auth: res = self.library._auth() self.assertEqual(res, upstream_return, "Good auth returns whatever upstream sent") self.assertEqual(mock_auth.call_args[1]['device'], 'auto') def test_34_auth_passcode(self): """ Test passcode on auth checks. """ os.environ['password'] = '******' creds = OpenVPNCredentials() creds.load_variables_from_environment() self.library.user_config = creds with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=socket.error): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=RuntimeError): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') upstream_return = ['some', 34, 'thing'] with mock.patch.object(self.library, 'auth', return_value=upstream_return) as mock_auth: res = self.library._auth() self.assertEqual(res, upstream_return, "Good auth returns whatever upstream sent") self.assertEqual(mock_auth.call_args[1]['passcode'], self.library.user_config.passcode) def test_35_auth_push(self): """ Test push on auth checks. """ self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=socket.error): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, 'auth', side_effect=RuntimeError): res = self.library._auth() self.assertIsNone(res, "Failed _auth must return None") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') upstream_return = ['some', 35, 'thing'] with mock.patch.object(self.library, 'auth', return_value=upstream_return) as mock_auth: res = self.library._auth() self.assertEqual(res, upstream_return, "Good auth returns whatever upstream sent") self.assertEqual(mock_auth.call_args[1]['device'], 'auto') def test_40_do_mfa(self): """ Unit test for _do_mfa_for_user """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_auth', return_value=None): res = self.library._do_mfa_for_user() self.assertFalse( res, "Failed _auth must cause _do_mfa_for_user to be False") with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_auth', return_value='weirdness'): res = self.library._do_mfa_for_user() self.assertFalse( res, "Garbage _auth must cause _do_mfa_for_user to be False") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_auth', return_value={'result': 'allow'}): res = self.library._do_mfa_for_user() self.assertTrue( res, "Allowed _auth must cause _do_mfa_for_user to be True") self.assertEqual(mock_log.call_args[1]['details']['success'], 'true') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_auth', return_value={ 'result': 'deny', 'status_msg': 'Nope' }): res = self.library._do_mfa_for_user() self.assertFalse( res, "Denied _auth must cause _do_mfa_for_user to be False") self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') def test_50_main_auth_awful1(self): """ Test main_auth that fail preflight """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preflight', return_value=False): self.library._fail_open = True #with mock.patch.object(self.library, '_fail_open', return_value=True): res = self.library.main_auth() self.assertTrue(res, "main_auth follows fail_open when preflight fails") self.assertEqual(mock_log.call_args[1]['details']['success'], 'true') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preflight', return_value=False): self.library._fail_open = False res = self.library.main_auth() self.assertFalse(res, "main_auth follows fail_open when preflight fails") mock_log.assert_not_called() def test_51_main_auth_awful2(self): """ Test main_auth that fail preauth """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_preflight', return_value=True): with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preauth', return_value=None): res = self.library.main_auth() self.assertFalse(res, "main_auth should fail when preauth fails") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preauth', return_value='junk'): res = self.library.main_auth() self.assertFalse( res, "main_auth should fail when preauth has an API failure") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') def test_52_main_auth_allow(self): """ Test main_auth when it gets an 'allow' response """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_preflight', return_value=True): with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preauth', return_value={'result': 'allow'}): res = self.library.main_auth() self.assertTrue( res, "main_auth should allow when preauth gets an allow") self.assertEqual(mock_log.call_args[1]['details']['success'], 'true') def test_53_main_auth_enroll(self): """ Test main_auth when it gets an 'enroll' response """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_preflight', return_value=True): with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preauth', return_value={'result': 'enroll'}): res = self.library.main_auth() self.assertFalse( res, "main_auth should deny when preauth gets an enroll") self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') def test_54_main_auth_deny(self): """ Test main_auth when it gets a 'deny' response """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_preflight', return_value=True): with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preauth', return_value={'result': 'deny'}): res = self.library.main_auth() self.assertFalse(res, "main_auth should deny when preauth gets a deny") self.assertEqual(mock_log.call_args[1]['details']['success'], 'false') def test_55_main_auth_auth(self): """ Test main_auth when it gets an 'auth' response """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_preflight', return_value=True): with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object(self.library, '_preauth', return_value={'result': 'auth'}): upstream_return = ['some', 55, 'thing'] with mock.patch.object(self.library, '_do_mfa_for_user', return_value=upstream_return): res = self.library.main_auth() self.assertEqual(res, upstream_return, "main_auth should deny when preauth gets a deny") mock_log.assert_not_called() def test_59_main_auth_nonsense(self): """ Test main_auth when it gets an unexpected response """ # It doesn't particularly matter what kind of user we have, so picked 'push' *shrug* self.library.user_config = self.user_data['push'] with mock.patch.object(self.library, '_preflight', return_value=True): with mock.patch.object(self.library, 'log') as mock_log: with mock.patch.object( self.library, '_preauth', return_value={'result': 'never_seen_this'}): res = self.library.main_auth() self.assertFalse( res, "main_auth should deny when preauth gets something unexpected") self.assertEqual(mock_log.call_args[1]['details']['error'], 'true') self.assertEqual(mock_log.call_args[1]['details']['success'], 'false')
def main_authentication(self): # pylint: disable=too-many-return-statements """ The main authentication function. Return True if the user successfully authenticated. Return False if they didn't. It's expected that we'll handle errors within this and not raise. """ # Set up a user object based on the environmental variables. user_data = OpenVPNCredentials() try: user_data.load_variables_from_environment() except ValueError: # pragma: no cover # This happens when we have a total mismatch, like, openvpn # didn't send valid environmental variables to the plugin, # or someone got here without a certificate(!?!) self.log(summary='FAIL: VPN environmental load, software bug', severity='ERROR', details={'error': 'true', 'success': 'false', },) traceback.print_exc() return False username = user_data.username client_ipaddr = user_data.client_ipaddr password = user_data.password iam_searcher = iamvpnlibrary.IAMVPNLibrary() if not iam_searcher.user_allowed_to_vpn(username): # Here we have a user not allowed to VPN in at all. # This is some form of "their account is disabled" and/or # they aren't in the approved ACL list. self.log(summary=('FAIL: VPN user "{}" administratively ' 'denied'.format(username)), severity='INFO', details={'username': username, 'sourceipaddress': client_ipaddr, 'success': 'false', },) return False if not iam_searcher.does_user_require_vpn_mfa(username): # We've hit a special snowflake user who does not require MFA. if password is None: # If a password of 'None' makes it through, auth will fail. # 'None' can happen if someone tries using a Duo keyword as a # password. In the unbelievably small chance that password # is correct, it's an unacceptably bad password. Punt them. # I mean, really, you've got a non-MFA user, who then has no # password / a horrible one. That's zero factors. allow_1fa = False else: allow_1fa = iam_searcher.non_mfa_vpn_authentication(username, password) if allow_1fa: summary = 'SUCCESS: non-MFA user "{}" accepted by password' else: summary = 'FAIL: non-MFA user "{}" denied by password' self.log(summary=summary.format(username), severity='INFO', details={'username': username, 'sourceipaddress': client_ipaddr, 'success': str(allow_1fa).lower(), },) return allow_1fa # We don't establish a Duo object until we need it. duo = DuoAPIAuth(fail_open=self.failopen, log_func=self.log, **self.duo_client_args) if not duo.load_user_to_verify(user_config=user_data): # pragma: no cover # The load_user_to_verify method is benign, so we should # never fail to load, but if we do it'll be hard to find, # so this 'if' block captures an edge case we've never seen, # just in case. self.log(summary='FAIL: VPN user failed MFA pre-load', severity='INFO', details={'username': username, 'sourceipaddress': client_ipaddr, 'success': 'false', },) return False try: return duo.main_auth() except Exception: # pragma: no cover pylint: disable=broad-except # Deliberately catch all errors until we can find what can # go wrong. self.log(summary='FAIL: VPN User auth failed, software bug', severity='ERROR', details={'username': username, 'error': 'true', 'sourceipaddress': client_ipaddr, 'success': 'false', },) traceback.print_exc() return False # We should never get here. self.log(summary='FAIL: VPN User fell through all possible ' 'Duo checks, software bug', severity='ERROR', details={'username': username, 'error': 'true', 'success': 'false', },) # pragma: no cover return False # pragma: no cover
class TestDuoAPIAuth(unittest.TestCase): # pylint: disable=too-many-public-methods """ These are intended to exercise internal functions of the library's DuoOpenVPN class. """ def setUp(self): """ Preparing test rig """ # To get a decent test, we're going to need items from the config # file in order to test. with mock.patch.object(DuoOpenVPN, 'CONFIG_FILE_LOCATIONS', new=[ 'duo_openvpn.conf', '/usr/local/etc/duo_openvpn.conf', '/etc/openvpn/duo_openvpn.conf', '/etc/duo_openvpn.conf' ]): self.main_object = DuoOpenVPN() try: self.normal_user = self.main_object.configfile.get( 'testing', 'normal_user') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover return self.skipTest('No testing/normal_user defined') try: self.deep_test_rawauth = self.main_object.configfile.getboolean( 'testing', 'deep_testing_rawauth') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover self.deep_test_rawauth = False try: self.deep_test_mfa = self.main_object.configfile.getboolean( 'testing', 'deep_testing_mfa') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover self.deep_test_mfa = False try: self.deep_test_main = self.main_object.configfile.getboolean( 'testing', 'deep_testing_mainauth') except (configparser.NoOptionError, configparser.NoSectionError): # pragma: no cover self.deep_test_main = False # os.environ['untrusted_ip'] = 'testing-ip-Unknown-is-OK' os.environ['common_name'] = self.normal_user user_creds = dict() for varname in OpenVPNCredentials.DUO_RESERVED_WORDS: os.environ['password'] = varname res = OpenVPNCredentials() res.load_variables_from_environment() user_creds[varname] = res self.user_data = user_creds # self.library = DuoAPIAuth(**self.main_object.duo_client_args) def tearDown(self): """ Clear the env so we don't impact other tests """ for varname in ['common_name', 'password', 'username', 'untrusted_ip']: if varname in os.environ: del os.environ[varname] def test_init(self): """ Verify init does the right thing """ self.assertIsInstance( self.library, duo_client.Auth, 'our DuoAPIAuth must be a descendant ' 'of duo_client.Auth') self.assertIsInstance(self.library.hostname, str, 'hostname must be set and a string') self.assertIsInstance(self.library._fail_open, bool, '_fail_open must return a bool') self.assertIsNone(self.library.user_config, 'user_config must be empty on a plain init') self.assertIsNone(self.library.log_func, 'log_func must default to None') def test_fail_open_00(self): """ _fail_open performs as expected """ res = self.library._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertFalse(res, '_fail_open defaults to False') def test_fail_open_01(self): """ _fail_open performs as expected on a True """ tmplibrary = DuoAPIAuth(fail_open=True, **self.main_object.duo_client_args) res = tmplibrary._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertTrue(res, '_fail_open must return an expected True') def test_fail_open_02(self): """ _fail_open performs as expected on a False """ tmplibrary = DuoAPIAuth(fail_open=False, **self.main_object.duo_client_args) res = tmplibrary._fail_open self.assertIsInstance(res, bool, '_fail_open must return a bool') self.assertFalse(res, '_fail_open must return an expected False') def test_load_user_to_verify_00(self): """ load_user_to_verify can fail for garbage """ res = self.library.load_user_to_verify({}) self.assertFalse(res, 'load_user_to_verify must be False ' 'for junk input') def test_load_user_to_verify_01(self): """ load_user_to_verify can fail for a bad username """ setattr(self.user_data['push'], 'username', 0) res = self.library.load_user_to_verify(self.user_data['push']) self.assertFalse( res, 'load_user_to_verify must be False ' 'for a bad username') def test_load_user_to_verify_02(self): """ load_user_to_verify can fail for a bad factor """ setattr(self.user_data['push'], 'factor', 0) res = self.library.load_user_to_verify(self.user_data['push']) self.assertFalse( res, 'load_user_to_verify must be False ' 'for a bad factor') def test_load_user_to_verify_03(self): """ load_user_to_verify can succeed and return True """ res = self.library.load_user_to_verify(self.user_data['push']) self.assertTrue(res, 'load_user_to_verify must be True') def test_preflight_01(self): """ preflight fails for bad host """ self.main_object.duo_client_args['host'] = 'badhost' tmplibrary = DuoAPIAuth(**self.main_object.duo_client_args) # No user data needs to be loaded to be preflighted res = tmplibrary._preflight() self.assertFalse(res, '_preflight must be False for a bad host') def test_preflight_02(self): """ preflight fails for bad skey """ if not self.deep_test_rawauth: # pragma: no cover return self.skipTest('because of .deep_testing preference') self.main_object.duo_client_args['skey'] = 'wrong-passcode' tmplibrary = DuoAPIAuth(**self.main_object.duo_client_args) # No user data needs to be loaded to be preflighted res = tmplibrary._preflight() self.assertFalse(res, '_preflight must be False for a bad skey') def test_preflight_03(self): """ preflight works in general """ # No user data needs to be loaded to be preflighted res = self.library._preflight() self.assertTrue(res, '_preflight must be True ' 'for our standard testing') def test_preauth_01(self): """ preauth that can't work returns None """ self.main_object.duo_client_args['host'] = 'badhost' tmplibrary = DuoAPIAuth(**self.main_object.duo_client_args) tmplibrary.load_user_to_verify(self.user_data['push']) res = tmplibrary._preauth() self.assertIsNone(res, '_preauth on a damaged server returns None') def test_preauth_02(self): """ preauth errors on blank users """ setattr(self.user_data['push'], 'username', '') self.library.load_user_to_verify(self.user_data['push']) res = self.library._preauth() self.assertIsNone(res, '_preauth for a blank user must return None') def test_preauth_03(self): """ preauth wants to enroll for unknown users """ setattr(self.user_data['push'], 'username', 'baddie-bad-username') self.library.load_user_to_verify(self.user_data['push']) res = self.library._preauth() self.assertIsInstance(res, dict, '_preauth returns a dict upon success') self.assertIn('result', res, '_preauth return must have a "result" key') self.assertIn(res['result'], ['deny', 'enroll'], ('_preauth for an unknown user ' 'must return "deny" or "enroll"')) def test_preauth_04(self): """ preauth wants to auth standard user """ self.library.load_user_to_verify(self.user_data['push']) res = self.library._preauth() self.assertIsInstance(res, dict, '_preauth returns a dict upon success') self.assertIn('result', res, '_preauth return must have a "result" key') self.assertEqual(res['result'], 'auth', '_preauth for an known person must return "auth"') # We do not preauth-test a user that should only have 1FA. # The code should intercept a user and bypass checking before # getting to this point. If you're into preauth, you've lost. def test_auth_00(self): """ _auth with garbage factor fails """ setattr(self.user_data['auto'], 'factor', 'junk') self.library.load_user_to_verify(self.user_data['auto']) res = self.library._auth() self.assertIsNone(res, '_auth with junk factor must return None') def test_mfa_00(self): """ _do_mfa_for_user with garbage factor fails """ setattr(self.user_data['auto'], 'factor', 'junk') self.library.load_user_to_verify(self.user_data['auto']) res = self.library._do_mfa_for_user() self.assertFalse( res, '_do_mfa_for_user with junk factor ' 'must return False') def test_auth_02(self): """ _auth with sms fails """ self.library.load_user_to_verify(self.user_data['sms']) # We test this even with an incapable device. It should just fail. res = self.library._auth() self.assertIsNone(res, '_auth with "sms" factor must return None') def test_mfa_02(self): """ _do_mfa_for_user with sms fails """ self.library.load_user_to_verify(self.user_data['sms']) # We test this even with an incapable device. It should just fail. res = self.library._do_mfa_for_user() self.assertFalse( res, '_do_mfa_for_user with "sms" factor ' 'must return False') def _can_we_run_a_test(self, testcase): res = self.library._preauth() for device in res['devices']: if 'capabilities' in device: if testcase in device['capabilities']: return True return False def _auth_testing_run(self, testcase, answer): if not self.deep_test_rawauth: # pragma: no cover return self.skipTest('because of .deep_testing preference') self.library.load_user_to_verify(self.user_data[testcase]) if testcase != 'passcode' and not self._can_we_run_a_test(testcase): return self.skipTest( 'incapable device for {tc}'.format(tc=testcase)) res = self.library._auth() self.assertIsInstance(res, dict, '_auth must return a dict') self.assertIn('result', res, '_auth return must have a "result" key') self.assertIn(res['result'], ['allow', 'deny'], '_auth result must be "allow" or "deny"') self.assertEqual(res['result'], answer, '_auth result must be "{ans}"'.format(ans=answer)) def _mfa_testing_run(self, testcase, answer): if not self.deep_test_mfa: # pragma: no cover return self.skipTest('because of .deep_testing preference') self.library.load_user_to_verify(self.user_data[testcase]) if testcase != 'passcode' and not self._can_we_run_a_test(testcase): return self.skipTest( 'incapable device for {tc}'.format(tc=testcase)) res = self.library._do_mfa_for_user() self.assertIsInstance(res, bool, '_do_mfa_for_user must return a bool') self.assertEqual( res, answer, '_do_mfa_for_user result must ' 'be "{ans}"'.format(ans=answer)) def test_auth_03(self): """ _auth with auto - PLEASE ALLOW """ return self._auth_testing_run('auto', 'allow') def test_mfa_03(self): """ _do_mfa_for_user with auto - PLEASE ALLOW """ return self._mfa_testing_run('auto', True) def test_auth_04(self): """ _auth with phone - PLEASE ALLOW """ return self._auth_testing_run('phone', 'allow') def test_mfa_04(self): """ _do_mfa_for_user with phone - PLEASE ALLOW """ return self._mfa_testing_run('phone', True) def test_auth_05(self): """ _auth with push - PLEASE ALLOW """ return self._auth_testing_run('push', 'allow') def test_mfa_05(self): """ _do_mfa_for_user with push - PLEASE ALLOW """ return self._mfa_testing_run('push', True) def test_auth_06(self): """ _auth with VALID PASSCODE """ if not self.deep_test_rawauth: # pragma: no cover return self.skipTest('because of .deep_testing preference') passcode = six.moves.input('enter a valid passcode: ') os.environ['password'] = passcode creds = OpenVPNCredentials() creds.load_variables_from_environment() self.user_data['passcode'] = creds return self._auth_testing_run('passcode', 'allow') def test_mfa_06(self): """ _auth with VALID PASSCODE """ if not self.deep_test_mfa: # pragma: no cover return self.skipTest('because of .deep_testing preference') passcode = six.moves.input('enter a valid passcode: ') os.environ['password'] = passcode creds = OpenVPNCredentials() creds.load_variables_from_environment() self.user_data['passcode'] = creds return self._mfa_testing_run('passcode', True) def test_auth_13(self): """ _auth with auto - PLEASE DENY """ return self._auth_testing_run('auto', 'deny') def test_mfa_13(self): """ _do_mfa_for_user with auto - PLEASE DENY """ return self._mfa_testing_run('auto', False) def test_auth_14(self): """ _auth with phone - PLEASE DENY """ return self._auth_testing_run('phone', 'deny') def test_mfa_14(self): """ _do_mfa_for_user with phone - PLEASE DENY """ return self._mfa_testing_run('phone', False) def test_auth_15(self): """ _auth with push - PLEASE DENY """ return self._auth_testing_run('push', 'deny') def test_mfa_15(self): """ _do_mfa_for_user with push - PLEASE DENY """ return self._mfa_testing_run('push', False) def test_auth_16(self): """ _auth with INVALID PASSCODE """ # We are going to play the 1-in-a-million odds here and save # a click. Change up the lines if you hate this. # if not self.deep_test_rawauth: # return self.skipTest('because of .deep_testing preference') # passcode = six.moves.input('enter an invalid passcode: ') passcode = '000000' os.environ['password'] = passcode creds = OpenVPNCredentials() creds.load_variables_from_environment() self.user_data['passcode'] = creds return self._auth_testing_run('passcode', 'deny') def test_mfa_16(self): """ _do_mfa_for_user with INVALID PASSCODE """ # We are going to play the 1-in-a-million odds here and save # a click. Change up the lines if you hate this. # if not self.deep_test_mfa: # return self.skipTest('because of .deep_testing preference') # passcode = six.moves.input('enter an invalid passcode: ') passcode = '000000' os.environ['password'] = passcode creds = OpenVPNCredentials() creds.load_variables_from_environment() self.user_data['passcode'] = creds return self._mfa_testing_run('passcode', False) # A word on testing main_auth. # main_auth has exceptions in it, for handling the cases of a server # failing open, and bad runtime responses coming out of it. We're # not doing a full test in here, because the sheer number of options # we'd have to test would be INSANE. The subtests will have to handle # some of this, and trust that you'll eyeball the function for "if # we're broken at runtime, that 'if' statement near the top will save us. # As such, we are mostly testing the return fields coming out of # _auth, and that we're getting the right answers. For the most part, # all of these checks will look uncannily similar to the _auth and # _mfa checks above. def test_main_00(self): """ main_auth with garbage factor fails """ setattr(self.user_data['auto'], 'factor', 'junk') self.library.load_user_to_verify(self.user_data['auto']) res = self.library.main_auth() self.assertFalse(res, 'main_auth with junk factor ' 'must return False') def test_main_01(self): """ main_auth with no connection goes fail_open """ self.main_object.duo_client_args['host'] = 'badhost' for state in (True, False): tmplibrary = DuoAPIAuth(fail_open=state, **self.main_object.duo_client_args) tmplibrary.load_user_to_verify(self.user_data['auto']) res = tmplibrary.main_auth() self.assertEqual( res, state, 'main_auth with no connection must return ' 'the fail_open state') def test_main_02(self): """ main_auth with sms fails """ self.library.load_user_to_verify(self.user_data['sms']) # We test this even with an incapable device. It should just fail. res = self.library.main_auth() self.assertFalse(res, 'main_auth with "sms" factor ' 'must return False') def _main_testing_run(self, testcase, answer): if not self.deep_test_main: # pragma: no cover return self.skipTest('because of .deep_testing preference') self.library.load_user_to_verify(self.user_data[testcase]) if testcase != 'passcode' and not self._can_we_run_a_test(testcase): return self.skipTest( 'incapable device for {tc}'.format(tc=testcase)) res = self.library.main_auth() self.assertIsInstance(res, bool, 'main_auth must return a bool') self.assertEqual( res, answer, 'main_auth result must ' 'be "{ans}"'.format(ans=answer)) def test_main_03(self): """ main_auth with auto - PLEASE ALLOW """ return self._main_testing_run('auto', True) def test_main_04(self): """ main_auth with phone - PLEASE ALLOW """ return self._main_testing_run('phone', True) def test_main_05(self): """ main_auth with push - PLEASE ALLOW """ return self._main_testing_run('push', True) def test_main_06(self): """ _auth with VALID PASSCODE """ if not self.deep_test_main: # pragma: no cover return self.skipTest('because of .deep_testing preference') passcode = six.moves.input('enter a valid passcode: ') os.environ['password'] = passcode creds = OpenVPNCredentials() creds.load_variables_from_environment() self.user_data['passcode'] = creds return self._main_testing_run('passcode', True) def test_main_13(self): """ main_auth with auto - PLEASE DENY """ return self._main_testing_run('auto', False) def test_main_14(self): """ main_auth with phone - PLEASE DENY """ return self._main_testing_run('phone', False) def test_main_15(self): """ main_auth with push - PLEASE DENY """ return self._main_testing_run('push', False) def test_main_16(self): """ main_auth with INVALID PASSCODE """ # We are going to play the 1-in-a-million odds here and save # a click. Change up the lines if you hate this. # if not self.deep_test_main: # return self.skipTest('because of .deep_testing preference') # passcode = six.moves.input('enter an invalid passcode: ') passcode = '000000' os.environ['password'] = passcode creds = OpenVPNCredentials() creds.load_variables_from_environment() self.user_data['passcode'] = creds return self._main_testing_run('passcode', False) def test_log_good(self): """ Test sending a log message - all good """ # There is no raise or return. We're just poking at # the function and making sure it doesn't raise. tmplibrary = DuoAPIAuth(log_func=self.main_object.log, **self.main_object.duo_client_args) tmplibrary.log( summary='TEST message', severity='DEBUG', )