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:
@ -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
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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/',
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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);
|
||||
});
|
||||
|
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'))
|
||||
);
|
||||
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 />} />
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user