2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 06:01:35 +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.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

View File

@@ -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

View File

@@ -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):