2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 19:45:46 +00:00

[PUI] Login / Logout State Fixes (#6368)

* Fix API endpoint URLs

* Adds "authenticated" field to root API endpoint

* Load global status data separately

- Create new global state manager
- Load *after* login
- Prevents auth popup dialog and failure messages

* Add launch config for frontend dev

* Update docs

* Clear token auth if no token is defined

* remove unneeded import

* Revert format of InfoView endpoint

* Remove "authenticated" from InfoView

* Refactor is_staff token check

- Using new get_token_from_request method

* Cleanup code

- return early

* URL fixes

- More fixes for incorrect api calls

* Better tracking of authenticated status

- track an internal flag in apiState

* Prioritize token auth

* Only fetch userState if authenticated

* Force unauthenticated state on first launch

* Updates to login procedure

- Rename doClassicLogin to doBasicLogin (reflecting "basic" auth)
- Add "loggedIn" attribute to sessionState
- Cleanup procedure for securing a token

* Abort early on checkLoginState

- Prevent failed calls to user_me

* Refactoring

- Simpler to just track token state
- No need for separate status tracker
- Works much cleaner this way

* Remove debug messages

* Cleanup unused imports

* Fix unused variable

* Revert timeout to 2000ms

* Rename doClassicLogout -> doLogout

* Improvements for checkLoginState

- Account for the presence of a CSRF session cookie
- If available, use it to fetch a token

* Clear CSRF cookie on logout

- Forces logout from session
- Tested, works well!
- Clean up notifications

* Cleanup setApiDefaults method

* fix global logout (PUI -> CUI)

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2024-02-02 12:02:55 +11:00
committed by GitHub
parent ec2a66e7a5
commit f97cdef9fc
22 changed files with 332 additions and 156 deletions

View File

@ -1,22 +1,42 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { getCsrfCookie } from './functions/auth';
import { useLocalState } from './states/LocalState';
import { useSessionState } from './states/SessionState';
// API
// Global API instance
export const api = axios.create({});
/*
* Setup default settings for the Axios API instance.
*
* This includes:
* - Base URL
* - Authorization token (if available)
* - CSRF token (if available)
*/
export function setApiDefaults() {
const host = useLocalState.getState().host;
const token = useSessionState.getState().token;
api.defaults.baseURL = host;
api.defaults.headers.common['Authorization'] = `Token ${token}`;
// CSRF support (needed for POST, PUT, PATCH, DELETE)
api.defaults.withCredentials = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
if (!!token) {
api.defaults.headers.common['Authorization'] = `Token ${token}`;
} else {
api.defaults.headers.common['Authorization'] = undefined;
}
if (!!getCsrfCookie()) {
api.defaults.withCredentials = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
} else {
api.defaults.withCredentials = false;
api.defaults.xsrfCookieName = undefined;
api.defaults.xsrfHeaderName = undefined;
}
}
export const queryClient = new QueryClient();

View File

@ -18,8 +18,9 @@ import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { doClassicLogin, doSimpleLogin } from '../../functions/auth';
import { doBasicLogin, doSimpleLogin } from '../../functions/auth';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useSessionState } from '../../states/SessionState';
export function AuthenticationForm() {
const classicForm = useForm({
@ -36,19 +37,13 @@ export function AuthenticationForm() {
setIsLoggingIn(true);
if (classicLoginMode === true) {
doClassicLogin(
doBasicLogin(
classicForm.values.username,
classicForm.values.password
).then((ret) => {
).then(() => {
setIsLoggingIn(false);
if (ret === false) {
notifications.show({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
});
} else {
if (useSessionState.getState().hasToken()) {
notifications.show({
title: t`Login successful`,
message: t`Welcome back!`,
@ -56,6 +51,12 @@ export function AuthenticationForm() {
icon: <IconCheck size="1rem" />
});
navigate('/home');
} else {
notifications.show({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
});
}
});
} else {

View File

@ -7,13 +7,14 @@ import {
IconUserBolt,
IconUserCog
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { doClassicLogout } from '../../functions/auth';
import { doLogout } from '../../functions/auth';
import { InvenTreeStyle } from '../../globalStyle';
import { useUserState } from '../../states/UserState';
export function MainMenu() {
const navigate = useNavigate();
const { classes, theme } = InvenTreeStyle();
const userState = useUserState();
@ -63,7 +64,7 @@ export function MainMenu() {
<Menu.Item
icon={<IconLogout />}
onClick={() => {
doClassicLogout();
doLogout(navigate);
}}
>
<Trans>Logout</Trans>

View File

@ -2,7 +2,7 @@ import { Badge, Center, MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings';
import { ModelType } from '../../enums/ModelType';
import { useServerApiState } from '../../states/ApiState';
import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
key: string;
@ -72,7 +72,7 @@ export const StatusRenderer = ({
type: ModelType | string;
options?: renderStatusLabelOptionsInterface;
}) => {
const statusCodeList = useServerApiState.getState().status;
const statusCodeList = useGlobalStatusState.getState().status;
if (status === undefined) {
console.log('StatusRenderer: status is undefined');

View File

@ -47,7 +47,6 @@ function SettingValue({
settingsState.fetchSettings();
})
.catch((error) => {
console.log('Error editing setting', error);
showNotification({
title: t`Error editing setting`,
message: error.message,

View File

@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react';
import { api } from '../App';
import { useServerApiState } from '../states/ApiState';
import { useLocalState } from '../states/LocalState';
import { fetchGlobalStates } from '../states/states';
// Definitions
export type Locales = keyof typeof languages | 'pseudo-LOCALE';
@ -90,8 +91,8 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
// Update default Accept-Language headers
api.defaults.headers.common['Accept-Language'] = locales.join(', ');
// Reload server state (refresh status codes)
useServerApiState.getState().fetchServerApiState();
// Reload server state (and refresh status codes)
fetchGlobalStates();
// Clear out cached table column names
useLocalState.getState().clearTableColumnNames();

View File

@ -1,77 +1,90 @@
import { t } from '@lingui/macro';
import { notifications, showNotification } from '@mantine/notifications';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import axios from 'axios';
import { api } from '../App';
import { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl, useServerApiState } from '../states/ApiState';
import { apiUrl } from '../states/ApiState';
import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../states/SettingsState';
import { useUserState } from '../states/UserState';
export const doClassicLogin = async (username: string, password: string) => {
const tokenName: string = 'inventree-web-app';
/**
* Attempt to login using username:password combination.
* 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) => {
const { host } = useLocalState.getState();
// const apiState = useServerApiState.getState();
// Get token from server
const token = await axios
if (username.length == 0 || password.length == 0) {
return;
}
// At this stage, we can assume that we are not logged in, and we have no token
useSessionState.getState().clearToken();
// Request new token from the server
await axios
.get(apiUrl(ApiEndpoints.user_token), {
auth: { username, password },
baseURL: host,
timeout: 2000,
params: {
name: 'inventree-web-app'
name: tokenName
}
})
.then((response) => response.data.token)
.catch((error) => {
showNotification({
title: t`Login failed`,
message: t`Error fetching token from server.`,
color: 'red'
});
return false;
});
if (token === false) return token;
// log in with token
doTokenLogin(token);
return true;
.then((response) => {
if (response.status == 200 && response.data.token) {
// A valid token has been returned - save, and login
useSessionState.getState().setToken(response.data.token);
}
})
.catch(() => {});
};
/**
* Logout the user (invalidate auth token)
* Logout the user from the current session
*
* @arg deleteToken: If true, delete the token from the server
*/
export const doClassicLogout = async () => {
// Set token in context
const { setToken } = useSessionState.getState();
setToken(undefined);
export const doLogout = async (navigate: any) => {
// Logout from the server session
await api.post(apiUrl(ApiEndpoints.user_logout));
// Logout from this session
// Note that clearToken() then calls setApiDefaults()
clearCsrfCookie();
useSessionState.getState().clearToken();
notifications.hide('login');
notifications.show({
id: 'login',
title: t`Logout successful`,
message: t`You have been logged out`,
color: 'green',
icon: <IconCheck size="1rem" />
});
return true;
navigate('/login');
};
export const doSimpleLogin = async (email: string) => {
const { host } = useLocalState.getState();
const mail = await axios
.post(apiUrl(ApiEndpoints.user_simple_login), {
email: email
})
.post(
apiUrl(ApiEndpoints.user_simple_login),
{
email: email
},
{
baseURL: host,
timeout: 2000
}
)
.then((response) => response.data)
.catch((_error) => {
return false;
@ -79,21 +92,6 @@ export const doSimpleLogin = async (email: string) => {
return mail;
};
// Perform a login using a token
export const doTokenLogin = (token: string) => {
const { setToken } = useSessionState.getState();
const { fetchUserState } = useUserState.getState();
const { fetchServerApiState } = useServerApiState.getState();
const globalSettingsState = useGlobalSettingsState.getState();
const userSettingsState = useUserSettingsState.getState();
setToken(token);
fetchUserState();
fetchServerApiState();
globalSettingsState.fetchSettings();
userSettingsState.fetchSettings();
};
export function handleReset(navigate: any, values: { email: string }) {
api
.post(apiUrl(ApiEndpoints.user_reset), values, {
@ -119,36 +117,96 @@ export function handleReset(navigate: any, values: { email: string }) {
}
/**
* Check login state, and redirect the user as required
* Check login state, and redirect the user as required.
*
* The user may be logged in via the following methods:
* - An existing API token is stored in the session
* - An existing CSRF cookie is stored in the browser
*/
export function checkLoginState(
navigate: any,
redirect?: string,
no_redirect?: boolean
) {
api
.get(apiUrl(ApiEndpoints.user_token), {
timeout: 2000,
params: {
name: 'inventree-web-app'
}
})
.then((val) => {
if (val.status === 200 && val.data.token) {
doTokenLogin(val.data.token);
setApiDefaults();
notifications.show({
title: t`Already logged in`,
message: t`Found an existing login - using it to log you in.`,
color: 'green',
icon: <IconCheck size="1rem" />
});
navigate(redirect ?? '/home');
} else {
navigate('/login');
}
})
.catch(() => {
if (!no_redirect) navigate('/login');
// Callback function when login is successful
const loginSuccess = () => {
notifications.hide('login');
notifications.show({
id: 'login',
title: t`Logged In`,
message: t`Found an existing login - welcome back!`,
color: 'green',
icon: <IconCheck size="1rem" />
});
navigate(redirect ?? '/home');
};
// Callback function when login fails
const loginFailure = () => {
useSessionState.getState().clearToken();
if (!no_redirect) navigate('/login');
};
if (useSessionState.getState().hasToken()) {
// An existing token is available - check if it works
api
.get(apiUrl(ApiEndpoints.user_me), {
timeout: 2000
})
.then((val) => {
if (val.status === 200) {
// Success: we are logged in (and we already have a token)
loginSuccess();
} else {
loginFailure();
}
})
.catch(() => {
loginFailure();
});
} else if (getCsrfCookie()) {
// Try to fetch a new token using the CSRF cookie
api
.get(apiUrl(ApiEndpoints.user_token), {
params: {
name: tokenName
}
})
.then((response) => {
if (response.status == 200 && response.data.token) {
useSessionState.getState().setToken(response.data.token);
loginSuccess();
} else {
loginFailure();
}
})
.catch(() => {
loginFailure();
});
} else {
// No token, no cookie - redirect to login page
loginFailure();
}
}
/*
* Return the value of the CSRF cookie, if available
*/
export function getCsrfCookie() {
const cookieValue = document.cookie
.split('; ')
.find((row) => row.startsWith('csrftoken='))
?.split('=')[1];
return cookieValue;
}
/*
* Clear out the CSRF cookie (force session logout)
*/
export function clearCsrfCookie() {
document.cookie =
'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}

View File

@ -49,6 +49,7 @@ export default function Login() {
// check if user is logged in in PUI
checkLoginState(navigate, undefined, true);
}, []);
// Fetch server data on mount if no server data is present
useEffect(() => {
if (server.server === null) {

View File

@ -2,20 +2,14 @@ import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App';
import { StatusCodeListInterface } from '../components/render/StatusRenderer';
import { statusCodeList } from '../defaults/backendMappings';
import { emptyServerAPI } from '../defaults/defaults';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { AuthProps, ServerAPIProps } from './states';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerApiStateProps {
server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => void;
status?: StatusLookup;
auth_settings?: AuthProps;
}
@ -31,19 +25,9 @@ export const useServerApiState = create<ServerApiStateProps>()(
.then((response) => {
set({ server: response.data });
})
.catch(() => {});
// Fetch status data for rendering labels
await api
.get(apiUrl(ApiEndpoints.global_status))
.then((response) => {
const newStatusLookup: StatusLookup = {} as StatusLookup;
for (const key in response.data) {
newStatusLookup[statusCodeList[key] || key] =
response.data[key].values;
}
set({ status: newStatusLookup });
})
.catch(() => {});
.catch(() => {
console.error('Error fetching server info');
});
// Fetch login/SSO behaviour
await api
@ -53,7 +37,9 @@ export const useServerApiState = create<ServerApiStateProps>()(
.then((response) => {
set({ auth_settings: response.data });
})
.catch(() => {});
.catch(() => {
console.error('Error fetching SSO information');
});
},
status: undefined
}),

View File

@ -2,20 +2,32 @@ import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { setApiDefaults } from '../App';
import { fetchGlobalStates } from './states';
interface SessionStateProps {
token?: string;
setToken: (newToken?: string) => void;
clearToken: () => void;
hasToken: () => boolean;
}
/*
* State manager for user login information.
*/
export const useSessionState = create<SessionStateProps>()(
persist(
(set) => ({
token: '',
(set, get) => ({
token: undefined,
clearToken: () => {
set({ token: undefined });
},
setToken: (newToken) => {
set({ token: newToken });
setApiDefaults();
}
fetchGlobalStates();
},
hasToken: () => !!get().token
}),
{
name: 'session-state',

View File

@ -7,6 +7,7 @@ import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { isTrue } from '../functions/conversion';
import { PathParams, apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { Setting, SettingsLookup } from './states';
export interface SettingsStateProps {
@ -28,6 +29,10 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
lookup: {},
endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
return;
}
await api
.get(apiUrl(ApiEndpoints.settings_global_list))
.then((response) => {
@ -58,6 +63,10 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
lookup: {},
endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
return;
}
await api
.get(apiUrl(ApiEndpoints.settings_user_list))
.then((response) => {

View File

@ -0,0 +1,51 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App';
import { StatusCodeListInterface } from '../components/render/StatusRenderer';
import { statusCodeList } from '../defaults/backendMappings';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerStateProps {
status?: StatusLookup;
setStatus: (newStatus: StatusLookup) => void;
fetchStatus: () => void;
}
export const useGlobalStatusState = create<ServerStateProps>()(
persist(
(set) => ({
status: undefined,
setStatus: (newStatus: StatusLookup) => set({ status: newStatus }),
fetchStatus: async () => {
// Fetch status data for rendering labels
if (!useSessionState.getState().hasToken()) {
return;
}
await api
.get(apiUrl(ApiEndpoints.global_status))
.then((response) => {
const newStatusLookup: StatusLookup = {} as StatusLookup;
for (const key in response.data) {
newStatusLookup[statusCodeList[key] || key] =
response.data[key].values;
}
set({ status: newStatusLookup });
})
.catch(() => {
console.error('Error fetching global status information');
});
}
}),
{
name: 'global-status-state',
storage: createJSONStorage(() => sessionStorage)
}
)
);

View File

@ -3,8 +3,8 @@ import { create } from 'zustand';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { UserPermissions, UserRoles } from '../enums/Roles';
import { doClassicLogout } from '../functions/auth';
import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { UserProps } from './states';
interface UserStateProps {
@ -35,6 +35,10 @@ export const useUserState = create<UserStateProps>((set, get) => ({
},
setUser: (newUser: UserProps) => set({ user: newUser }),
fetchUserState: async () => {
if (!useSessionState.getState().hasToken()) {
return;
}
// Fetch user data
await api
.get(apiUrl(ApiEndpoints.user_me), {
@ -52,8 +56,6 @@ export const useUserState = create<UserStateProps>((set, get) => ({
})
.catch((error) => {
console.error('Error fetching user data:', error);
// Redirect to login page
doClassicLogout();
});
// Fetch role data

View File

@ -1,3 +1,9 @@
import { setApiDefaults } from '../App';
import { useSessionState } from './SessionState';
import { useGlobalSettingsState, useUserSettingsState } from './SettingsState';
import { useGlobalStatusState } from './StatusState';
import { useUserState } from './UserState';
export interface Host {
host: string;
name: string;
@ -111,3 +117,20 @@ export type ErrorResponse = {
export type SettingsLookup = {
[key: string]: string;
};
/*
* Refetch all global state information.
* Necessary on login, or if locale is changed.
*/
export function fetchGlobalStates() {
if (!useSessionState.getState().hasToken()) {
return;
}
setApiDefaults();
useUserState.getState().fetchUserState();
useUserSettingsState.getState().fetchSettings();
useGlobalSettingsState.getState().fetchSettings();
useGlobalStatusState.getState().fetchStatus();
}

View File

@ -1,7 +1,7 @@
import { t } from '@lingui/macro';
import { ModelType } from '../enums/ModelType';
import { useServerApiState } from '../states/ApiState';
import { useGlobalStatusState } from '../states/StatusState';
/**
* Interface for the table filter choice
@ -60,7 +60,7 @@ export function StatusFilterOptions(
model: ModelType
): () => TableFilterChoice[] {
return () => {
const statusCodeList = useServerApiState.getState().status;
const statusCodeList = useGlobalStatusState.getState().status;
if (!statusCodeList) {
return [];

View File

@ -2,7 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { queryClient, setApiDefaults } from '../App';
import { queryClient } from '../App';
import { BaseContext } from '../contexts/BaseContext';
import { defaultHostList } from '../defaults/defaultHostList';
import { base_url } from '../main';
@ -26,9 +26,6 @@ export default function DesktopAppView() {
state.fetchSettings
]);
// Local state initialization
setApiDefaults();
// Server Session
const [fetchedServerSession, setFetchedServerSession] = useState(false);
const sessionState = useSessionState.getState();

View File

@ -1,6 +1,7 @@
import { useViewportSize } from '@mantine/hooks';
import { lazy } from 'react';
import { lazy, useEffect } from 'react';
import { setApiDefaults } from '../App';
import { Loadable } from '../functions/loading';
function checkMobile() {
@ -14,6 +15,12 @@ const DesktopAppView = Loadable(lazy(() => import('./DesktopAppView')));
// Main App
export default function MainView() {
// Set initial login status
useEffect(() => {
// Local state initialization
setApiDefaults();
}, []);
// Check if mobile
if (checkMobile()) {
return <MobileAppView />;