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:
@@ -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
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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):
|
||||||
|
Reference in New Issue
Block a user