diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da80f8efb..3216bd21f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 19e191b5d5..91d12dac31 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -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' diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 1ee2ba3806..c3c3ecdd8b 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -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', diff --git a/src/frontend/package.json b/src/frontend/package.json index 82e25fa63d..76aaefc739 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 4130bf0676..80877c33e7 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -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 +) { + 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); + } +} diff --git a/src/frontend/src/pages/Auth/MFA.tsx b/src/frontend/src/pages/Auth/MFA.tsx index 30b59add86..06d7dadff7 100644 --- a/src/frontend/src/pages/Auth/MFA.tsx +++ b/src/frontend/src/pages/Auth/MFA.tsx @@ -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(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 ( - + {(mfa_types.includes('recovery_codes') || mfa_types.includes('totp')) && ( + + )} + { + 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() { View )} + {token.type == 'webauthn' && ( + + )} )); @@ -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: + }); + } + + // 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: + }); + 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, 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); diff --git a/src/frontend/src/states/ServerApiState.tsx b/src/frontend/src/states/ServerApiState.tsx index 6e0428f7f9..43acde80f8 100644 --- a/src/frontend/src/states/ServerApiState.tsx +++ b/src/frontend/src/states/ServerApiState.tsx @@ -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()( 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); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 38d9bc6734..e6f08e6190 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -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"