mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-29 12:27:41 +00:00
feat(frontend): add passkey/webauthn for secondary MFA (#9729)
* bump allauth * add trust * add device trust handling * fix style * [FR] Add passkey as a factor Fixes #4002 * add registration * allow better testing * add mfa context * fix login * add changelog entry * fix registration * remove multi device packages * move to helper * handle mfa trust * simplify page fnc
This commit is contained in:
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650)
|
||||
- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654)
|
||||
- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657)
|
||||
- Added support for webauthn login for the frontend in [#9729](https://github.com/inventree/InvenTree/pull/9729)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1386,10 +1386,12 @@ MFA_ENABLED = get_boolean_setting(
|
||||
MFA_SUPPORTED_TYPES = get_setting(
|
||||
'INVENTREE_MFA_SUPPORTED_TYPES',
|
||||
'mfa_supported_types',
|
||||
['totp', 'recovery_codes'],
|
||||
['totp', 'recovery_codes', 'webauthn'],
|
||||
typecast=list,
|
||||
)
|
||||
MFA_TRUST_ENABLED = True
|
||||
MFA_PASSKEY_LOGIN_ENABLED = True
|
||||
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
|
||||
|
||||
LOGOUT_REDIRECT_URL = get_setting(
|
||||
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
|
||||
|
||||
@@ -32,6 +32,8 @@ export enum ApiEndpoints {
|
||||
auth_mfa_reauthenticate = 'auth/v1/auth/2fa/reauthenticate',
|
||||
auth_totp = 'auth/v1/account/authenticators/totp',
|
||||
auth_trust = 'auth/v1/auth/2fa/trust',
|
||||
auth_webauthn = 'auth/v1/account/authenticators/webauthn',
|
||||
auth_webauthn_login = 'auth/v1/auth/webauthn/authenticate',
|
||||
auth_reauthenticate = 'auth/v1/auth/reauthenticate',
|
||||
auth_email = 'auth/v1/account/email',
|
||||
auth_email_verify = 'auth/v1/auth/email/verify',
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@lingui/core": "^5.3.1",
|
||||
"@lingui/react": "^5.3.1",
|
||||
"@mantine/carousel": "^8.2.7",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
type CredentialRequestOptionsJSON,
|
||||
get,
|
||||
parseRequestOptionsFromJSON
|
||||
} from '@github/webauthn-json/browser-ponyfill';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
||||
@@ -71,7 +76,7 @@ export async function doBasicLogin(
|
||||
const { getHost } = useLocalState.getState();
|
||||
const { clearUserState, setAuthenticated, fetchUserState } =
|
||||
useUserState.getState();
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
const { setAuthContext, setMfaContext } = useServerApiState.getState();
|
||||
|
||||
if (username.length == 0 || password.length == 0) {
|
||||
return;
|
||||
@@ -157,6 +162,7 @@ export async function doBasicLogin(
|
||||
(flow: any) => flow.id == FlowEnum.MfaAuthenticate
|
||||
);
|
||||
if (mfa_flow?.is_pending) {
|
||||
setMfaContext(mfa_flow);
|
||||
// MFA is required - we might already have a code
|
||||
if (code && code.length > 0) {
|
||||
const rslt = await handleMfaLogin(
|
||||
@@ -680,3 +686,57 @@ export function handleChangePassword(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWebauthnLogin(
|
||||
navigate?: NavigateFunction,
|
||||
location?: Location<any>
|
||||
) {
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
|
||||
const webauthn_challenge = api
|
||||
.get(apiUrl(ApiEndpoints.auth_webauthn_login))
|
||||
.catch(() => {})
|
||||
.then((response) => {
|
||||
if (response && response.status === 200) {
|
||||
return response.data.data.request_options;
|
||||
}
|
||||
});
|
||||
|
||||
if (!webauthn_challenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await get(
|
||||
parseRequestOptionsFromJSON(
|
||||
webauthn_challenge as CredentialRequestOptionsJSON
|
||||
)
|
||||
);
|
||||
await api
|
||||
.post(apiUrl(ApiEndpoints.auth_webauthn_login), {
|
||||
credential: credential
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
handleSuccessFullAuth(response, navigate, location, undefined);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.status == 401) {
|
||||
const mfa_trust = err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == FlowEnum.MfaTrust
|
||||
);
|
||||
if (mfa_trust?.is_pending) {
|
||||
setAuthContext(err.response.data.data);
|
||||
authApi(apiUrl(ApiEndpoints.auth_trust), undefined, 'post', {
|
||||
trust: false
|
||||
}).then((response) => {
|
||||
handleSuccessFullAuth(response, navigate, location, undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Button, Checkbox, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { handleMfaLogin } from '../../functions/auth';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { handleMfaLogin, handleWebauthnLogin } from '../../functions/auth';
|
||||
import { useServerApiState } from '../../states/ServerApiState';
|
||||
import { Wrapper } from './Layout';
|
||||
|
||||
export default function Mfa() {
|
||||
@@ -12,17 +14,30 @@ export default function Mfa() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [loginError, setLoginError] = useState<string | undefined>(undefined);
|
||||
const [mfa_context] = useServerApiState(
|
||||
useShallow((state) => [state.mfa_context])
|
||||
);
|
||||
const mfa_types = mfa_context?.types || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (mfa_types.includes('webauthn') || mfa_types.includes('webauthn_2fa')) {
|
||||
handleWebauthnLogin(navigate, location);
|
||||
}
|
||||
}, [mfa_types]);
|
||||
|
||||
return (
|
||||
<Wrapper titleText={t`Multi-Factor Authentication`} logOff>
|
||||
<TextInput
|
||||
required
|
||||
label={t`TOTP Code`}
|
||||
name='TOTP'
|
||||
description={t`Enter your TOTP or recovery code`}
|
||||
{...simpleForm.getInputProps('code')}
|
||||
error={loginError}
|
||||
/>
|
||||
{(mfa_types.includes('recovery_codes') || mfa_types.includes('totp')) && (
|
||||
<TextInput
|
||||
required
|
||||
label={t`TOTP Code`}
|
||||
name='TOTP'
|
||||
description={t`Enter one of your codes: ${mfa_types}`}
|
||||
{...simpleForm.getInputProps('code')}
|
||||
error={loginError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={t`Remember this device`}
|
||||
name='remember'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { create } from '@github/webauthn-json/browser-ponyfill';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { type AuthConfig, type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
||||
@@ -449,6 +450,18 @@ function MfaSection() {
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
const removeWebauthn = (code: string) => {
|
||||
runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'delete', {
|
||||
authenticators: [code]
|
||||
}).then(() => {
|
||||
refetch();
|
||||
return ResultType.success;
|
||||
}),
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (isLoading || !data) return null;
|
||||
@@ -468,6 +481,16 @@ function MfaSection() {
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{token.type == 'webauthn' && (
|
||||
<Button
|
||||
color='red'
|
||||
onClick={() => {
|
||||
removeWebauthn(token.id);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
@@ -619,6 +642,62 @@ function MfaAddSection({
|
||||
getReauthText
|
||||
);
|
||||
};
|
||||
const registerWebauthn = async () => {
|
||||
let data: any = {};
|
||||
await runActionWithFallback(
|
||||
() =>
|
||||
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'get').then(
|
||||
(res) => {
|
||||
data = res.data?.data;
|
||||
if (data?.creation_options) {
|
||||
return ResultType.success;
|
||||
} else {
|
||||
return ResultType.error;
|
||||
}
|
||||
}
|
||||
),
|
||||
getReauthText
|
||||
);
|
||||
if (data?.creation_options == undefined) {
|
||||
showNotification({
|
||||
title: t`Error while registering WebAuthn authenticator`,
|
||||
message: t`Please reload page and try again.`,
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
}
|
||||
|
||||
// register the webauthn authenticator with the browser
|
||||
const resp = await create({
|
||||
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(
|
||||
data.creation_options.publicKey
|
||||
)
|
||||
});
|
||||
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'post', {
|
||||
name: 'Master Key',
|
||||
credential: JSON.stringify(resp)
|
||||
})
|
||||
.then(() => {
|
||||
showNotification({
|
||||
title: t`WebAuthn authenticator registered successfully`,
|
||||
message: t`You can now use this authenticator for multi-factor authentication.`,
|
||||
color: 'green'
|
||||
});
|
||||
refetch();
|
||||
return ResultType.success;
|
||||
})
|
||||
.catch((err) => {
|
||||
showNotification({
|
||||
title: t`Error while registering WebAuthn authenticator`,
|
||||
message: err.response.data.errors
|
||||
.map((error: any) => error.message)
|
||||
.join('\n'),
|
||||
color: 'red',
|
||||
icon: <IconX />
|
||||
});
|
||||
return ResultType.error;
|
||||
});
|
||||
};
|
||||
|
||||
const possibleFactors = useMemo(() => {
|
||||
return [
|
||||
@@ -635,6 +714,13 @@ function MfaAddSection({
|
||||
description: t`One-Time pre-generated recovery codes`,
|
||||
function: registerRecoveryCodes,
|
||||
used: usedFactors?.includes('recovery_codes')
|
||||
},
|
||||
{
|
||||
type: 'webauthn',
|
||||
name: t`WebAuthn`,
|
||||
description: t`Web Authentication (WebAuthn) is a web standard for secure authentication`,
|
||||
function: registerWebauthn,
|
||||
used: usedFactors?.includes('webauthn')
|
||||
}
|
||||
].filter((factor) => {
|
||||
return auth_config?.mfa?.supported_types.includes(factor.type);
|
||||
@@ -718,16 +804,18 @@ async function runActionWithFallback(
|
||||
action: () => Promise<ResultType>,
|
||||
getReauthText: (props: any) => any
|
||||
) {
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
const { setAuthContext, setMfaContext, mfa_context } =
|
||||
useServerApiState.getState();
|
||||
|
||||
const result = await action().catch((err) => {
|
||||
setAuthContext(err.response.data?.data);
|
||||
// check if we need to re-authenticate
|
||||
if (err.status == 401) {
|
||||
if (
|
||||
err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
|
||||
)
|
||||
) {
|
||||
const mfaFlow = err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
|
||||
);
|
||||
if (mfaFlow) {
|
||||
setMfaContext(mfaFlow);
|
||||
return ResultType.mfareauth;
|
||||
} else if (
|
||||
err.response.data.data.flows.find(
|
||||
@@ -742,13 +830,18 @@ async function runActionWithFallback(
|
||||
return ResultType.error;
|
||||
}
|
||||
});
|
||||
|
||||
// run the re-authentication flows as needed
|
||||
if (result == ResultType.mfareauth) {
|
||||
const mfa_types = mfa_context?.types || [];
|
||||
const mfaCode = await getReauthText({
|
||||
label: t`TOTP Code`,
|
||||
name: 'TOTP',
|
||||
description: t`Enter one of your codes: ${mfa_types}`
|
||||
});
|
||||
|
||||
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
|
||||
code: await getReauthText({
|
||||
label: t`TOTP Code`,
|
||||
name: 'TOTP',
|
||||
description: t`Enter your TOTP or recovery code`
|
||||
})
|
||||
code: mfaCode
|
||||
})
|
||||
.then((response) => {
|
||||
setAuthContext(response.data?.data);
|
||||
@@ -758,12 +851,14 @@ async function runActionWithFallback(
|
||||
setAuthContext(err.response.data?.data);
|
||||
});
|
||||
} else if (result == ResultType.reauth) {
|
||||
const passwordInput = await getReauthText({
|
||||
label: t`Password`,
|
||||
name: 'password',
|
||||
description: t`Enter your password`
|
||||
});
|
||||
|
||||
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
|
||||
password: await getReauthText({
|
||||
label: t`Password`,
|
||||
name: 'password',
|
||||
description: t`Enter your password`
|
||||
})
|
||||
password: passwordInput
|
||||
})
|
||||
.then((response) => {
|
||||
setAuthContext(response.data?.data);
|
||||
|
||||
@@ -15,6 +15,9 @@ interface ServerApiStateProps {
|
||||
auth_config?: AuthConfig;
|
||||
auth_context?: AuthContext;
|
||||
setAuthContext: (auth_context: AuthContext | undefined) => void;
|
||||
mfa_context?: any;
|
||||
setMfaContext: (mfa_context: any) => void;
|
||||
// Helper functions
|
||||
sso_enabled: () => boolean;
|
||||
registration_enabled: () => boolean;
|
||||
sso_registration_enabled: () => boolean;
|
||||
@@ -61,6 +64,10 @@ export const useServerApiState = create<ServerApiStateProps>()(
|
||||
setAuthContext(auth_context) {
|
||||
set({ auth_context });
|
||||
},
|
||||
mfa_context: undefined,
|
||||
setMfaContext(mfa_context) {
|
||||
set({ mfa_context });
|
||||
},
|
||||
sso_enabled: () => {
|
||||
const data = get().auth_config?.socialaccount.providers;
|
||||
return !(data === undefined || data.length == 0);
|
||||
|
||||
@@ -888,6 +888,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@fullcalendar/react/-/react-6.1.19.tgz#921ecc92972af4c9654e4742dd4a73fd4092c2ae"
|
||||
integrity sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==
|
||||
|
||||
"@github/webauthn-json@^2.1.1":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc"
|
||||
integrity sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==
|
||||
|
||||
"@isaacs/cliui@^8.0.2":
|
||||
version "8.0.2"
|
||||
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
||||
|
||||
Reference in New Issue
Block a user