diff --git a/src/backend/InvenTree/InvenTree/auth_overrides.py b/src/backend/InvenTree/InvenTree/auth_overrides.py index 11e33d539e..09f89d68e5 100644 --- a/src/backend/InvenTree/InvenTree/auth_overrides.py +++ b/src/backend/InvenTree/InvenTree/auth_overrides.py @@ -11,12 +11,14 @@ from django.utils.translation import gettext_lazy as _ from allauth.account.adapter import DefaultAccountAdapter from allauth.account.forms import LoginForm, SignupForm, set_form_field_order +from allauth.headless.tokens.sessions import SessionTokenStrategy from allauth.socialaccount.adapter import DefaultSocialAccountAdapter import InvenTree.helpers_model import InvenTree.sso from common.settings import get_global_setting from InvenTree.exceptions import log_error +from users.models import ApiToken logger = logging.getLogger('inventree') @@ -219,3 +221,12 @@ class CustomSocialAccountAdapter( # Log the error to the database log_error(path, error_name=error, error_data=exception) logger.error("SSO error for provider '%s' - check admin error log", provider_id) + + +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 diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 8870d9cf11..e08cd24432 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -20,6 +20,7 @@ from django.core.validators import URLValidator from django.http import Http404 import structlog +from corsheaders.defaults import default_headers from dotenv import load_dotenv from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -1165,6 +1166,9 @@ USE_X_FORWARDED_PORT = get_boolean_setting( # Refer to the django-cors-headers documentation for more information # Ref: https://github.com/adamchainz/django-cors-headers + +CORS_ALLOW_HEADERS = (*default_headers, 'x-session-token') + # Extract CORS options from configuration file CORS_ALLOW_ALL_ORIGINS = get_boolean_setting( 'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG @@ -1290,6 +1294,7 @@ HEADLESS_FRONTEND_URLS = { 'account_signup': 'https://app.org/account/signup', } HEADLESS_ONLY = True +HEADLESS_TOKEN_STRATEGY = 'InvenTree.auth_overrides.DRFTokenStrategy' MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True) # Markdownify configuration diff --git a/src/frontend/src/components/forms/AuthenticationForm.tsx b/src/frontend/src/components/forms/AuthenticationForm.tsx index 1cd64f898c..4a144986ce 100644 --- a/src/frontend/src/components/forms/AuthenticationForm.tsx +++ b/src/frontend/src/components/forms/AuthenticationForm.tsx @@ -45,7 +45,11 @@ export function AuthenticationForm() { setIsLoggingIn(true); if (classicLoginMode === true) { - doBasicLogin(classicForm.values.username, classicForm.values.password) + doBasicLogin( + classicForm.values.username, + classicForm.values.password, + navigate + ) .then(() => { setIsLoggingIn(false); diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 0206dcd55f..da84bf8872 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -24,7 +24,8 @@ export enum ApiEndpoints { user_email_remove = 'auth/emails/:id/remove/', user_email_verify = 'auth/emails/:id/verify/', user_email_primary = 'auth/emails/:id/primary/', - user_login = 'auth/login/', + user_login = '_allauth/app/v1/auth/login', + user_login_mfa = '_allauth/app/v1/auth/2fa/authenticate', user_logout = 'auth/logout/', user_register = 'auth/registration/', diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 5e4aa69b71..b0aee89595 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -1,8 +1,7 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; import axios from 'axios'; -import type { NavigateFunction } from 'react-router-dom'; - +import type { Location, NavigateFunction } from 'react-router-dom'; import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { apiUrl } from '../states/ApiState'; @@ -59,9 +58,14 @@ function post(path: string, params: any, method = 'post') { * If login is successful, an API token will be returned. * This API token is used for any future API requests. */ -export const doBasicLogin = async (username: string, password: string) => { +export const doBasicLogin = async ( + username: string, + password: string, + navigate: NavigateFunction +) => { const { host } = useLocalState.getState(); - const { clearUserState, setToken, fetchUserState } = useUserState.getState(); + const { clearUserState, setToken, setSession, fetchUserState } = + useUserState.getState(); if (username.length == 0 || password.length == 0) { return; @@ -86,24 +90,20 @@ export const doBasicLogin = async (username: string, password: string) => { } ) .then((response) => { - if (response.status == 200) { - if (response.data.key) { - setToken(response.data.key); - result = true; - } + if (response.status == 200 && response.data?.meta?.is_authenticated) { + setToken(response.data.meta.access_token); + result = true; } }) .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 (err?.response?.status == 401) { + const mfa_flow = err.response.data.data.flows.find( + (flow: any) => flow.id == 'mfa_authenticate' + ); + if (mfa_flow && mfa_flow.is_pending == true) { + setSession(err.response.data.meta.session_token); + navigate('/mfa'); + } } }); @@ -158,7 +158,10 @@ export const doSimpleLogin = async (email: string) => { return mail; }; -export function handleReset(navigate: any, values: { email: string }) { +export function handleReset( + navigate: NavigateFunction, + values: { email: string } +) { api .post(apiUrl(ApiEndpoints.user_reset), values, { headers: { Authorization: '' } @@ -182,6 +185,27 @@ export function handleReset(navigate: any, values: { email: string }) { }); } +export function handleMfaLogin( + navigate: NavigateFunction, + location: Location, + values: { code: string } +) { + const { session, setToken } = useUserState.getState(); + + api + .post( + apiUrl(ApiEndpoints.user_login_mfa), + { + code: values.code + }, + { headers: { 'X-Session-Token': session } } + ) + .then((response) => { + setToken(response.data.meta.access_token); + followRedirect(navigate, location?.state); + }); +} + /** * Check login state, and redirect the user as required. * diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 9ef7ad799c..139a4fdf65 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -59,7 +59,8 @@ export default function Login() { if (searchParams.has('login') && searchParams.has('password')) { doBasicLogin( searchParams.get('login') ?? '', - searchParams.get('password') ?? '' + searchParams.get('password') ?? '', + navigate ).then(() => { followRedirect(navigate, location?.state); }); diff --git a/src/frontend/src/pages/Auth/MFALogin.tsx b/src/frontend/src/pages/Auth/MFALogin.tsx new file mode 100644 index 0000000000..5be5a2d994 --- /dev/null +++ b/src/frontend/src/pages/Auth/MFALogin.tsx @@ -0,0 +1,50 @@ +import { Trans, t } from '@lingui/macro'; +import { + Button, + Center, + Container, + Stack, + TextInput, + Title +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { LanguageContext } from '../../contexts/LanguageContext'; +import { handleMfaLogin } from '../../functions/auth'; + +export default function Reset() { + const simpleForm = useForm({ initialValues: { code: '' } }); + const navigate = useNavigate(); + const location = useLocation(); + + return ( + +
+ + + + <Trans>MFA Login</Trans> + + + + + + + +
+
+ ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index c444f9afeb..eb0dad939c 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -104,6 +104,7 @@ export const NotFound = Loadable( lazy(() => import('./components/errors/NotFound')) ); export const Login = Loadable(lazy(() => import('./pages/Auth/Login'))); +export const MFALogin = Loadable(lazy(() => import('./pages/Auth/MFALogin'))); export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout'))); export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In'))); export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset'))); @@ -165,6 +166,7 @@ export const routes = ( }> } />, + } />, } />, } /> } /> diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 8ba1abc88b..c5f7094286 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -16,6 +16,8 @@ export interface UserStateProps { setUser: (newUser: UserProps) => void; setToken: (newToken: string) => void; clearToken: () => void; + session: string | undefined; + setSession: (newSession: string) => void; fetchUserToken: () => void; fetchUserState: () => void; clearUserState: () => void; @@ -51,6 +53,10 @@ export const useUserState = create((set, get) => ({ set({ token: undefined }); setApiDefaults(); }, + session: undefined, + setSession: (newSession: string) => { + set({ session: newSession }); + }, userId: () => { const user: UserProps = get().user as UserProps; return user.pk;