2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 14:11:37 +00:00

chore(backend):mfa e2e testing (#10206)

* chore(backend):mfa e2e testing

* there are no tokens when only depoying browser-based clients for allauth

* cleanup

* style fixes
This commit is contained in:
Matthias Mair
2025-08-25 08:01:43 +02:00
committed by GitHub
parent dee26bfa46
commit 6c05742a3b
3 changed files with 66 additions and 40 deletions

View File

@@ -12,13 +12,11 @@ import structlog
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.headless.adapter import DefaultHeadlessAdapter from allauth.headless.adapter import DefaultHeadlessAdapter
from allauth.headless.tokens.sessions import SessionTokenStrategy
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
import InvenTree.sso import InvenTree.sso
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from users.models import ApiToken
from .helpers_email import is_email_configured from .helpers_email import is_email_configured
@@ -231,17 +229,8 @@ class CustomHeadlessAdapter(DefaultHeadlessAdapter):
if urlname not in HEADLESS_FRONTEND_URLS: if urlname not in HEADLESS_FRONTEND_URLS:
raise ValueError( raise ValueError(
f'URL name "{urlname}" not found in HEADLESS_FRONTEND_URLS' f'URL name "{urlname}" not found in HEADLESS_FRONTEND_URLS'
) ) # pragma: no cover
return self.request.build_absolute_uri( return self.request.build_absolute_uri(
f'/{settings.FRONTEND_URL_BASE}/{HEADLESS_FRONTEND_URLS[urlname].format(**kwargs)}' 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

View File

@@ -1356,7 +1356,6 @@ HEADLESS_ADAPTER = 'InvenTree.auth_overrides.CustomHeadlessAdapter'
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
HEADLESS_ONLY = True HEADLESS_ONLY = True
HEADLESS_TOKEN_STRATEGY = 'InvenTree.auth_overrides.DRFTokenStrategy'
HEADLESS_CLIENTS = 'browser' HEADLESS_CLIENTS = 'browser'
MFA_ENABLED = get_boolean_setting( MFA_ENABLED = get_boolean_setting(
'INVENTREE_MFA_ENABLED', 'mfa_enabled', True 'INVENTREE_MFA_ENABLED', 'mfa_enabled', True

View File

@@ -5,6 +5,8 @@ from django.contrib.auth.models import Group
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from allauth.mfa.totp.internal import auth as totp_auth
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
from users.models import ApiToken, Owner from users.models import ApiToken, Owner
@@ -332,47 +334,83 @@ class OwnerModelTest(InvenTreeTestCase):
class MFALoginTest(InvenTreeAPITestCase): class MFALoginTest(InvenTreeAPITestCase):
"""Some simplistic tests to ensure that MFA is working.""" """Some simplistic tests to ensure that MFA is working."""
""" mfa_secret = None
def test_api(self):
""Test that the API is working.""
auth_data = {'username': self.username, 'password': self.password}
login_url = reverse('api-login')
# Normal login def test_api(self):
response = self.post(login_url, auth_data, expected_code=200) """Test that the API is working."""
self.assertIn('key', response.data) 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() self.client.logout()
response = self.post(login_url, auth_data, expected_code=200)
self._helper_meta_val(response)
# Add MFA # 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 # Login with MFA enabled but not provided
response = self.post(login_url, auth_data, expected_code=403) self.client.logout()
self.assertContains(response, 'MFA required for this user', status_code=403) response = self.post(login_url, auth_data, expected_code=401)
self._helper_meta_val(response, val=False)
# Login with MFA enabled and provided - should redirect to MFA page self.assertEqual(self._helper_get_flow(response)['is_pending'], True)
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.get(reverse('api-token'), expected_code=401) 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 # Login with MFA enabled and provided - but incorrect pwd
self.client.logout()
auth_data['password'] = 'wrong' 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 auth_data['password'] = self.password
# Remove MFA def _helper_meta_val(
totp_model.delete() 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 def _helper_get_flow(self, response, flow_id: str = 'mfa_authenticate'):
response = self.post(login_url, auth_data, expected_code=200) """Helper to run a test on flow response."""
self.assertIn('key', response.data) flows = response.json()['data']['flows']
return next(a for a in flows if a['id'] == flow_id)
# Wrong login should not work def get_topt(self):
auth_data['password'] = 'wrong' """Helper to get a current totp code."""
self.post(login_url, auth_data, expected_code=401) 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): class AdminTest(AdminTestCase):