diff --git a/src/backend/InvenTree/InvenTree/auth_overrides.py b/src/backend/InvenTree/InvenTree/auth_overrides.py index 3e5599f8f1..9b23879838 100644 --- a/src/backend/InvenTree/InvenTree/auth_overrides.py +++ b/src/backend/InvenTree/InvenTree/auth_overrides.py @@ -12,13 +12,11 @@ import structlog from allauth.account.adapter import DefaultAccountAdapter from allauth.account.forms import LoginForm, SignupForm, set_form_field_order from allauth.headless.adapter import DefaultHeadlessAdapter -from allauth.headless.tokens.sessions import SessionTokenStrategy from allauth.socialaccount.adapter import DefaultSocialAccountAdapter import InvenTree.sso from common.settings import get_global_setting from InvenTree.exceptions import log_error -from users.models import ApiToken from .helpers_email import is_email_configured @@ -231,17 +229,8 @@ class CustomHeadlessAdapter(DefaultHeadlessAdapter): if urlname not in HEADLESS_FRONTEND_URLS: raise ValueError( f'URL name "{urlname}" not found in HEADLESS_FRONTEND_URLS' - ) + ) # pragma: no cover return self.request.build_absolute_uri( f'/{settings.FRONTEND_URL_BASE}/{HEADLESS_FRONTEND_URLS[urlname].format(**kwargs)}' ) - - -class DRFTokenStrategy(SessionTokenStrategy): - """Strategy that InvenTrees own included Token model.""" - - def create_access_token(self, request): - """Create a new access token for the user.""" - token, _ = ApiToken.objects.get_or_create(user=request.user) - return token.key diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index f82c673442..092574042c 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1356,7 +1356,6 @@ HEADLESS_ADAPTER = 'InvenTree.auth_overrides.CustomHeadlessAdapter' ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True HEADLESS_ONLY = True -HEADLESS_TOKEN_STRATEGY = 'InvenTree.auth_overrides.DRFTokenStrategy' HEADLESS_CLIENTS = 'browser' MFA_ENABLED = get_boolean_setting( 'INVENTREE_MFA_ENABLED', 'mfa_enabled', True diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index d5988ced24..41ee59bc2a 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -5,6 +5,8 @@ from django.contrib.auth.models import Group from django.test import TestCase from django.urls import reverse +from allauth.mfa.totp.internal import auth as totp_auth + from common.settings import set_global_setting from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase from users.models import ApiToken, Owner @@ -332,47 +334,83 @@ class OwnerModelTest(InvenTreeTestCase): class MFALoginTest(InvenTreeAPITestCase): """Some simplistic tests to ensure that MFA is working.""" - """ - def test_api(self): - ""Test that the API is working."" - auth_data = {'username': self.username, 'password': self.password} - login_url = reverse('api-login') + mfa_secret = None - # Normal login - response = self.post(login_url, auth_data, expected_code=200) - self.assertIn('key', response.data) + def test_api(self): + """Test that the API is working.""" + auth_data = {'username': self.username, 'password': self.password} + login_url = reverse('browser:account:login') + + # Double login is not allowed + self.post(login_url, auth_data, expected_code=409) + + # Normal login - no mfa self.client.logout() + response = self.post(login_url, auth_data, expected_code=200) + self._helper_meta_val(response) # Add MFA - totp_model = self.user.totpdevice_set.create() + response = self.post( + reverse('browser:mfa:manage_totp'), + {'code': self.get_topt()}, + expected_code=200, + ) + # There must be a TOTP device now - success + self.get(reverse('browser:mfa:manage_totp'), expected_code=200) + self.get(reverse('api-token'), expected_code=200) # Login with MFA enabled but not provided - response = self.post(login_url, auth_data, expected_code=403) - self.assertContains(response, 'MFA required for this user', status_code=403) - - # Login with MFA enabled and provided - should redirect to MFA page - auth_data['mfa'] = 'anything' - response = self.post(login_url, auth_data, expected_code=302) - self.assertEqual(response.url, reverse('two-factor-authenticate')) - # MFA not finished - no access allowed + self.client.logout() + response = self.post(login_url, auth_data, expected_code=401) + self._helper_meta_val(response, val=False) + self.assertEqual(self._helper_get_flow(response)['is_pending'], True) self.get(reverse('api-token'), expected_code=401) + # Login with MFA enabled and provided - second api call an success + self.client.logout() + response = self.post(login_url, auth_data, expected_code=401) + # MFA not finished - no access allowed + self.get(reverse('api-token'), expected_code=401) + # Complete + self.post( + reverse('browser:mfa:authenticate'), + {'code': self.get_topt()}, + expected_code=401, + ) + self.post(reverse('browser:mfa:trust'), {'trust': False}, expected_code=200) + # and run through trust + self.get(reverse('api-token'), expected_code=200) + # Login with MFA enabled and provided - but incorrect pwd + self.client.logout() auth_data['password'] = 'wrong' - self.post(login_url, auth_data, expected_code=401) + response = self.post(login_url, auth_data, expected_code=400) + self.assertContains( + response, + 'The username and/or password you specified are not correct', + status_code=400, + ) auth_data['password'] = self.password - # Remove MFA - totp_model.delete() + def _helper_meta_val( + self, response, key: str = 'is_authenticated', val: bool = True + ): + """Helper to run a test on meta response.""" + self.assertEqual(response.json()['meta'][key], val) - # Login with MFA disabled but correct credentials provided - response = self.post(login_url, auth_data, expected_code=200) - self.assertIn('key', response.data) + def _helper_get_flow(self, response, flow_id: str = 'mfa_authenticate'): + """Helper to run a test on flow response.""" + flows = response.json()['data']['flows'] + return next(a for a in flows if a['id'] == flow_id) - # Wrong login should not work - auth_data['password'] = 'wrong' - self.post(login_url, auth_data, expected_code=401) - """ + def get_topt(self): + """Helper to get a current totp code.""" + if not self.mfa_secret: + mfa_init = self.get(reverse('browser:mfa:manage_totp'), expected_code=404) + self.mfa_secret = mfa_init.json()['meta']['secret'] + return totp_auth.hotp_value( + self.mfa_secret, next(totp_auth.yield_hotp_counters_from_time()) + ) class AdminTest(AdminTestCase):