diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index a7b89e4ef1..e61de9a153 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -3,13 +3,17 @@ import datetime 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.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 allauth.account import app_settings 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 drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import exceptions, permissions @@ -238,12 +242,40 @@ class GroupList(ListCreateAPI): class Login(LoginView): """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): """Process the login request, ensure that MFA is enforced if required.""" # Normal login process ret = super().process_login() - - # Now check if MFA is enforced user = self.request.user adapter = get_adapter(self.request) diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index 44ea1ac1e8..786ab142a0 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import Group from django.test import TestCase, tag 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 @@ -265,3 +265,47 @@ class OwnerModelTest(InvenTreeTestCase): reverse('api-user-me'), {'name': 'another-token'}, 200 ) 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) diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index a9b66def18..0302e2ac75 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -1,15 +1,45 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; import axios from 'axios'; +import { NavigateFunction } from 'react-router-dom'; import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; -import { apiUrl } from '../states/ApiState'; +import { apiUrl, useServerApiState } from '../states/ApiState'; import { useLocalState } from '../states/LocalState'; import { useUserState } from '../states/UserState'; import { fetchGlobalStates } from '../states/states'; 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. * 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) { await fetchUserState(); @@ -65,7 +107,7 @@ export const doBasicLogin = async (username: string, password: string) => { * * @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(); // Logout from the server session