mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	[PUI] Add 2FA login (#7469)
* Add `2fa_urls`
* Add new fields to serializer
* Add new interface to PUI interfaces
* fix url resolving
* add frontend redirect for MFA login
* redirect login if mfa is required
* Merege upstream/master into branch
* reset default login
* remove mfa states
* fix auth args
* add handler for MFA redirect auth
* Revert "Merege upstream/master into branch"
This reverts commit 717001d8f1.
* revert api version bump
* revert frontend error handling change
* reduce complexity
* reset schema text
* Add e2e test for MFA login url
* accept either POST or body data for login pre-check
* remove CUI test
* style fixes
			
			
This commit is contained in:
		| @@ -3,13 +3,17 @@ | |||||||
| import datetime | import datetime | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from django.contrib.auth import get_user, login, logout | from django.contrib.auth import authenticate, get_user, login, logout | ||||||
| from django.contrib.auth.models import Group, Permission, User | from django.contrib.auth.models import Group, Permission, User | ||||||
| from django.db.models import Q | from django.db.models import Q | ||||||
| from django.urls import include, path, re_path | from django.http.response import HttpResponse | ||||||
|  | from django.shortcuts import redirect | ||||||
|  | from django.urls import include, path, re_path, reverse | ||||||
| from django.views.generic.base import RedirectView | from django.views.generic.base import RedirectView | ||||||
|  |  | ||||||
|  | from allauth.account import app_settings | ||||||
| from allauth.account.adapter import get_adapter | from allauth.account.adapter import get_adapter | ||||||
|  | from allauth_2fa.utils import user_has_valid_totp_device | ||||||
| from dj_rest_auth.views import LoginView, LogoutView | from dj_rest_auth.views import LoginView, LogoutView | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view | from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view | ||||||
| from rest_framework import exceptions, permissions | from rest_framework import exceptions, permissions | ||||||
| @@ -238,12 +242,40 @@ class GroupList(ListCreateAPI): | |||||||
| class Login(LoginView): | class Login(LoginView): | ||||||
|     """API view for logging in via API.""" |     """API view for logging in via API.""" | ||||||
|  |  | ||||||
|  |     def post(self, request, *args, **kwargs): | ||||||
|  |         """API view for logging in via API.""" | ||||||
|  |         _data = request.data.copy() | ||||||
|  |         _data.update(request.POST.copy()) | ||||||
|  |  | ||||||
|  |         if not _data.get('mfa', None): | ||||||
|  |             return super().post(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |         # Check if login credentials valid | ||||||
|  |         user = authenticate( | ||||||
|  |             request, username=_data.get('username'), password=_data.get('password') | ||||||
|  |         ) | ||||||
|  |         if user is None: | ||||||
|  |             return HttpResponse(status=401) | ||||||
|  |  | ||||||
|  |             # Check if user has mfa set up | ||||||
|  |         if not user_has_valid_totp_device(user): | ||||||
|  |             return super().post(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |             # Stage login and redirect to 2fa | ||||||
|  |         request.session['allauth_2fa_user_id'] = str(user.id) | ||||||
|  |         request.session['allauth_2fa_login'] = { | ||||||
|  |             'email_verification': app_settings.EMAIL_VERIFICATION, | ||||||
|  |             'signal_kwargs': None, | ||||||
|  |             'signup': False, | ||||||
|  |             'email': None, | ||||||
|  |             'redirect_url': reverse('platform'), | ||||||
|  |         } | ||||||
|  |         return redirect(reverse('two-factor-authenticate')) | ||||||
|  |  | ||||||
|     def process_login(self): |     def process_login(self): | ||||||
|         """Process the login request, ensure that MFA is enforced if required.""" |         """Process the login request, ensure that MFA is enforced if required.""" | ||||||
|         # Normal login process |         # Normal login process | ||||||
|         ret = super().process_login() |         ret = super().process_login() | ||||||
|  |  | ||||||
|         # Now check if MFA is enforced |  | ||||||
|         user = self.request.user |         user = self.request.user | ||||||
|         adapter = get_adapter(self.request) |         adapter = get_adapter(self.request) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ from django.contrib.auth.models import Group | |||||||
| from django.test import TestCase, tag | from django.test import TestCase, tag | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  |  | ||||||
| from InvenTree.unit_test import InvenTreeTestCase | from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase | ||||||
| from users.models import ApiToken, Owner, RuleSet | from users.models import ApiToken, Owner, RuleSet | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -265,3 +265,47 @@ class OwnerModelTest(InvenTreeTestCase): | |||||||
|             reverse('api-user-me'), {'name': 'another-token'}, 200 |             reverse('api-user-me'), {'name': 'another-token'}, 200 | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response['username'], self.username) |         self.assertEqual(response['username'], self.username) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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') | ||||||
|  |  | ||||||
|  |         # Normal login | ||||||
|  |         response = self.post(login_url, auth_data, expected_code=200) | ||||||
|  |         self.assertIn('key', response.data) | ||||||
|  |         self.client.logout() | ||||||
|  |  | ||||||
|  |         # Add MFA | ||||||
|  |         totp_model = self.user.totpdevice_set.create() | ||||||
|  |  | ||||||
|  |         # 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.get(reverse('api-token'), expected_code=401) | ||||||
|  |  | ||||||
|  |         # Login with MFA enabled and provided - but incorrect pwd | ||||||
|  |         auth_data['password'] = 'wrong' | ||||||
|  |         self.post(login_url, auth_data, expected_code=401) | ||||||
|  |         auth_data['password'] = self.password | ||||||
|  |  | ||||||
|  |         # Remove MFA | ||||||
|  |         totp_model.delete() | ||||||
|  |  | ||||||
|  |         # Login with MFA disabled but correct credentials provided | ||||||
|  |         response = self.post(login_url, auth_data, expected_code=200) | ||||||
|  |         self.assertIn('key', response.data) | ||||||
|  |  | ||||||
|  |         # Wrong login should not work | ||||||
|  |         auth_data['password'] = 'wrong' | ||||||
|  |         self.post(login_url, auth_data, expected_code=401) | ||||||
|   | |||||||
| @@ -1,15 +1,45 @@ | |||||||
| import { t } from '@lingui/macro'; | import { t } from '@lingui/macro'; | ||||||
| import { notifications } from '@mantine/notifications'; | import { notifications } from '@mantine/notifications'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
|  | import { NavigateFunction } from 'react-router-dom'; | ||||||
|  |  | ||||||
| import { api, setApiDefaults } from '../App'; | import { api, setApiDefaults } from '../App'; | ||||||
| import { ApiEndpoints } from '../enums/ApiEndpoints'; | import { ApiEndpoints } from '../enums/ApiEndpoints'; | ||||||
| import { apiUrl } from '../states/ApiState'; | import { apiUrl, useServerApiState } from '../states/ApiState'; | ||||||
| import { useLocalState } from '../states/LocalState'; | import { useLocalState } from '../states/LocalState'; | ||||||
| import { useUserState } from '../states/UserState'; | import { useUserState } from '../states/UserState'; | ||||||
| import { fetchGlobalStates } from '../states/states'; | import { fetchGlobalStates } from '../states/states'; | ||||||
| import { showLoginNotification } from './notifications'; | import { showLoginNotification } from './notifications'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * sends a request to the specified url from a form. this will change the window location. | ||||||
|  |  * @param {string} path the path to send the post request to | ||||||
|  |  * @param {object} params the parameters to add to the url | ||||||
|  |  * @param {string} [method=post] the method to use on the form | ||||||
|  |  * | ||||||
|  |  * Source https://stackoverflow.com/questions/133925/javascript-post-request-like-a-form-submit/133997#133997 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | function post(path: string, params: any, method = 'post') { | ||||||
|  |   const form = document.createElement('form'); | ||||||
|  |   form.method = method; | ||||||
|  |   form.action = path; | ||||||
|  |  | ||||||
|  |   for (const key in params) { | ||||||
|  |     if (params.hasOwnProperty(key)) { | ||||||
|  |       const hiddenField = document.createElement('input'); | ||||||
|  |       hiddenField.type = 'hidden'; | ||||||
|  |       hiddenField.name = key; | ||||||
|  |       hiddenField.value = params[key]; | ||||||
|  |  | ||||||
|  |       form.appendChild(hiddenField); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   document.body.appendChild(form); | ||||||
|  |   form.submit(); | ||||||
|  | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Attempt to login using username:password combination. |  * Attempt to login using username:password combination. | ||||||
|  * If login is successful, an API token will be returned. |  * If login is successful, an API token will be returned. | ||||||
| @@ -50,7 +80,19 @@ export const doBasicLogin = async (username: string, password: string) => { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }) |     }) | ||||||
|     .catch(() => {}); |     .catch((err) => { | ||||||
|  |       if ( | ||||||
|  |         err?.response.status == 403 && | ||||||
|  |         err?.response.data.detail == 'MFA required for this user' | ||||||
|  |       ) { | ||||||
|  |         post(apiUrl(ApiEndpoints.user_login), { | ||||||
|  |           username: username, | ||||||
|  |           password: password, | ||||||
|  |           csrfmiddlewaretoken: getCsrfCookie(), | ||||||
|  |           mfa: true | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|   if (result) { |   if (result) { | ||||||
|     await fetchUserState(); |     await fetchUserState(); | ||||||
| @@ -65,7 +107,7 @@ export const doBasicLogin = async (username: string, password: string) => { | |||||||
|  * |  * | ||||||
|  * @arg deleteToken: If true, delete the token from the server |  * @arg deleteToken: If true, delete the token from the server | ||||||
|  */ |  */ | ||||||
| export const doLogout = async (navigate: any) => { | export const doLogout = async (navigate: NavigateFunction) => { | ||||||
|   const { clearUserState, isLoggedIn } = useUserState.getState(); |   const { clearUserState, isLoggedIn } = useUserState.getState(); | ||||||
|  |  | ||||||
|   // Logout from the server session |   // Logout from the server session | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user