mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +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:
		| @@ -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