2
0
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:
Matthias Mair
2025-10-28 08:52:39 +01:00
committed by GitHub
parent 83f674e83f
commit 2e4b1d65f7
9 changed files with 216 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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