mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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 717001d8f1ad8ce291e79419f08450349190fbf3. * 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:
parent
73c10e219c
commit
39f3b900de
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user