2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 12:05:53 +00:00

Re-implement auth flow using new APIs; adds MFA to PUI

This commit is contained in:
Matthias Mair
2024-12-26 17:42:36 +01:00
parent 7334dc446a
commit e3c8b89b67
9 changed files with 127 additions and 23 deletions

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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/',

View File

@ -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<any>,
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.
*

View File

@ -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);
});

View File

@ -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 (
<LanguageContext>
<Center mih='100vh'>
<Container w='md' miw={425}>
<Stack>
<Title>
<Trans>MFA Login</Trans>
</Title>
<Stack>
<TextInput
required
label={t`Code`}
description={t`Enter your OTP or recovery code`}
{...simpleForm.getInputProps('code')}
/>
</Stack>
<Button
type='submit'
onClick={() =>
handleMfaLogin(navigate, location, simpleForm.values)
}
>
<Trans>Log in</Trans>
</Button>
</Stack>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -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 = (
</Route>
<Route path='/' errorElement={<ErrorPage />}>
<Route path='/login' element={<Login />} />,
<Route path='/mfa' element={<MFALogin />} />,
<Route path='/logout' element={<Logout />} />,
<Route path='/logged-in' element={<Logged_In />} />
<Route path='/reset-password' element={<Reset />} />

View File

@ -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<UserStateProps>((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;