mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 04:25:42 +00:00
Re-implement auth flow using new APIs; adds MFA to PUI
This commit is contained in:
@ -11,12 +11,14 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
|
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
|
||||||
|
from allauth.headless.tokens.sessions import SessionTokenStrategy
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
|
|
||||||
import InvenTree.helpers_model
|
import InvenTree.helpers_model
|
||||||
import InvenTree.sso
|
import InvenTree.sso
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
|
from users.models import ApiToken
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
@ -219,3 +221,12 @@ class CustomSocialAccountAdapter(
|
|||||||
# Log the error to the database
|
# Log the error to the database
|
||||||
log_error(path, error_name=error, error_data=exception)
|
log_error(path, error_name=error, error_data=exception)
|
||||||
logger.error("SSO error for provider '%s' - check admin error log", provider_id)
|
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
|
||||||
|
@ -20,6 +20,7 @@ from django.core.validators import URLValidator
|
|||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from corsheaders.defaults import default_headers
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
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
|
# Refer to the django-cors-headers documentation for more information
|
||||||
# Ref: https://github.com/adamchainz/django-cors-headers
|
# Ref: https://github.com/adamchainz/django-cors-headers
|
||||||
|
|
||||||
|
|
||||||
|
CORS_ALLOW_HEADERS = (*default_headers, 'x-session-token')
|
||||||
|
|
||||||
# Extract CORS options from configuration file
|
# Extract CORS options from configuration file
|
||||||
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
|
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
|
||||||
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG
|
'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',
|
'account_signup': 'https://app.org/account/signup',
|
||||||
}
|
}
|
||||||
HEADLESS_ONLY = True
|
HEADLESS_ONLY = True
|
||||||
|
HEADLESS_TOKEN_STRATEGY = 'InvenTree.auth_overrides.DRFTokenStrategy'
|
||||||
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
|
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
|
||||||
|
|
||||||
# Markdownify configuration
|
# Markdownify configuration
|
||||||
|
@ -45,7 +45,11 @@ export function AuthenticationForm() {
|
|||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
|
|
||||||
if (classicLoginMode === true) {
|
if (classicLoginMode === true) {
|
||||||
doBasicLogin(classicForm.values.username, classicForm.values.password)
|
doBasicLogin(
|
||||||
|
classicForm.values.username,
|
||||||
|
classicForm.values.password,
|
||||||
|
navigate
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsLoggingIn(false);
|
setIsLoggingIn(false);
|
||||||
|
|
||||||
|
@ -24,7 +24,8 @@ export enum ApiEndpoints {
|
|||||||
user_email_remove = 'auth/emails/:id/remove/',
|
user_email_remove = 'auth/emails/:id/remove/',
|
||||||
user_email_verify = 'auth/emails/:id/verify/',
|
user_email_verify = 'auth/emails/:id/verify/',
|
||||||
user_email_primary = 'auth/emails/:id/primary/',
|
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_logout = 'auth/logout/',
|
||||||
user_register = 'auth/registration/',
|
user_register = 'auth/registration/',
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
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 type { NavigateFunction } from 'react-router-dom';
|
import type { Location, 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 } 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.
|
* If login is successful, an API token will be returned.
|
||||||
* This API token is used for any future API requests.
|
* 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 { host } = useLocalState.getState();
|
||||||
const { clearUserState, setToken, fetchUserState } = useUserState.getState();
|
const { clearUserState, setToken, setSession, fetchUserState } =
|
||||||
|
useUserState.getState();
|
||||||
|
|
||||||
if (username.length == 0 || password.length == 0) {
|
if (username.length == 0 || password.length == 0) {
|
||||||
return;
|
return;
|
||||||
@ -86,24 +90,20 @@ export const doBasicLogin = async (username: string, password: string) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status == 200) {
|
if (response.status == 200 && response.data?.meta?.is_authenticated) {
|
||||||
if (response.data.key) {
|
setToken(response.data.meta.access_token);
|
||||||
setToken(response.data.key);
|
result = true;
|
||||||
result = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (
|
if (err?.response?.status == 401) {
|
||||||
err?.response?.status == 403 &&
|
const mfa_flow = err.response.data.data.flows.find(
|
||||||
err?.response?.data?.detail == 'MFA required for this user'
|
(flow: any) => flow.id == 'mfa_authenticate'
|
||||||
) {
|
);
|
||||||
post(apiUrl(ApiEndpoints.user_login), {
|
if (mfa_flow && mfa_flow.is_pending == true) {
|
||||||
username: username,
|
setSession(err.response.data.meta.session_token);
|
||||||
password: password,
|
navigate('/mfa');
|
||||||
csrfmiddlewaretoken: getCsrfCookie(),
|
}
|
||||||
mfa: true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -158,7 +158,10 @@ export const doSimpleLogin = async (email: string) => {
|
|||||||
return mail;
|
return mail;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function handleReset(navigate: any, values: { email: string }) {
|
export function handleReset(
|
||||||
|
navigate: NavigateFunction,
|
||||||
|
values: { email: string }
|
||||||
|
) {
|
||||||
api
|
api
|
||||||
.post(apiUrl(ApiEndpoints.user_reset), values, {
|
.post(apiUrl(ApiEndpoints.user_reset), values, {
|
||||||
headers: { Authorization: '' }
|
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.
|
* Check login state, and redirect the user as required.
|
||||||
*
|
*
|
||||||
|
@ -59,7 +59,8 @@ export default function Login() {
|
|||||||
if (searchParams.has('login') && searchParams.has('password')) {
|
if (searchParams.has('login') && searchParams.has('password')) {
|
||||||
doBasicLogin(
|
doBasicLogin(
|
||||||
searchParams.get('login') ?? '',
|
searchParams.get('login') ?? '',
|
||||||
searchParams.get('password') ?? ''
|
searchParams.get('password') ?? '',
|
||||||
|
navigate
|
||||||
).then(() => {
|
).then(() => {
|
||||||
followRedirect(navigate, location?.state);
|
followRedirect(navigate, location?.state);
|
||||||
});
|
});
|
||||||
|
50
src/frontend/src/pages/Auth/MFALogin.tsx
Normal file
50
src/frontend/src/pages/Auth/MFALogin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -104,6 +104,7 @@ export const NotFound = Loadable(
|
|||||||
lazy(() => import('./components/errors/NotFound'))
|
lazy(() => import('./components/errors/NotFound'))
|
||||||
);
|
);
|
||||||
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
|
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 Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
|
||||||
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
|
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
|
||||||
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
|
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
|
||||||
@ -165,6 +166,7 @@ export const routes = (
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path='/' errorElement={<ErrorPage />}>
|
<Route path='/' errorElement={<ErrorPage />}>
|
||||||
<Route path='/login' element={<Login />} />,
|
<Route path='/login' element={<Login />} />,
|
||||||
|
<Route path='/mfa' element={<MFALogin />} />,
|
||||||
<Route path='/logout' element={<Logout />} />,
|
<Route path='/logout' element={<Logout />} />,
|
||||||
<Route path='/logged-in' element={<Logged_In />} />
|
<Route path='/logged-in' element={<Logged_In />} />
|
||||||
<Route path='/reset-password' element={<Reset />} />
|
<Route path='/reset-password' element={<Reset />} />
|
||||||
|
@ -16,6 +16,8 @@ export interface UserStateProps {
|
|||||||
setUser: (newUser: UserProps) => void;
|
setUser: (newUser: UserProps) => void;
|
||||||
setToken: (newToken: string) => void;
|
setToken: (newToken: string) => void;
|
||||||
clearToken: () => void;
|
clearToken: () => void;
|
||||||
|
session: string | undefined;
|
||||||
|
setSession: (newSession: string) => void;
|
||||||
fetchUserToken: () => void;
|
fetchUserToken: () => void;
|
||||||
fetchUserState: () => void;
|
fetchUserState: () => void;
|
||||||
clearUserState: () => void;
|
clearUserState: () => void;
|
||||||
@ -51,6 +53,10 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
set({ token: undefined });
|
set({ token: undefined });
|
||||||
setApiDefaults();
|
setApiDefaults();
|
||||||
},
|
},
|
||||||
|
session: undefined,
|
||||||
|
setSession: (newSession: string) => {
|
||||||
|
set({ session: newSession });
|
||||||
|
},
|
||||||
userId: () => {
|
userId: () => {
|
||||||
const user: UserProps = get().user as UserProps;
|
const user: UserProps = get().user as UserProps;
|
||||||
return user.pk;
|
return user.pk;
|
||||||
|
Reference in New Issue
Block a user