I am trying to enroll a user with Multifactor authentication with Firebase following this setup guide: https://cloud.google.com/identity-platform/docs/web/mfa
I am struggling to figure out how to have my function wait for the user inputted verification code after the code is sent to the user’s phone (I think this is why the code is erroring.) My current code snippet below will throw this error after I click the Send Verification Code button: error: ‘auth/missing-verification-code’, message: ‘The phone auth credential was created with an empty SMS verification code.’
This is the first time I have implemented a MFA flow , so anyone have ideas on how I should be doing this? Thnaks!
import React, { Component } from 'react' import { store } from 'react-notifications-component'; import { Grid, Row, Col } from 'react-flexbox-grid'; import { withRouter } from 'react-router-dom'; import { Form, Formik } from 'formik'; import { NOTIFICATION } from '../../../utils/constants.js'; import { firestore, firebase } from "../../../Fire.js"; import { updateProfileSchema, updateProfilePhoneSchema, checkVCodeSchema } from "../../../utils/formSchemas" import { Hr, Recaptcha, Wrapper } from '../../../utils/styles/misc.js'; import { FField } from '../../../utils/styles/forms.js'; import { H1, Label, RedText, H2, LLink, GreenHoverText, SmText } from '../../../utils/styles/text.js'; import { MdGreenToInvBtn, MdInvToPrimaryBtn } from '../../../utils/styles/buttons.js'; class AdminProfile extends Component { constructor(props) { super(props) this.state = { user: "", codeSent: false, editingPhone: false, vCode: "", loading: { user: true } } } componentDidMount(){ this.unsubscribeUser = firestore.collection("users").doc(this.props.user.uid) .onSnapshot((doc) => { if(doc.exists){ let docWithMore = Object.assign({}, doc.data()); docWithMore.id = doc.id; this.setState({ user: docWithMore, loading: { user: false } }) } else { console.error("User doesn't exist.") } }); } componentWillUnmount() { if(this.unsubscribeUser){ this.unsubscribeUser(); } } sendVerificationCode = (values) => { store.addNotification({ title: "reCAPTCHA", message: `Please complete the reCAPTCHA below to continue.`, type: "success", ...NOTIFICATION }) window.recaptchaVerifier = new firebase.auth.RecaptchaVerifier('recaptcha', { 'callback': (response) => { this.props.user.multiFactor.getSession().then((multiFactorSession) => { // Specify the phone number and pass the MFA session. let phoneInfoOptions = { phoneNumber: values.phone, session: multiFactorSession }; let phoneAuthProvider = new firebase.auth.PhoneAuthProvider(); // Send SMS verification code. return phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, window.recaptchaVerifier); }).then(async (verificationId) => { this.setState({ codeSent: true }) // Ask user for the verification code. // TODO: how to do this async? do I need to split up my requests? // let code = await this.getAttemptedCode() let cred = firebase.auth.PhoneAuthProvider.credential(verificationId, this.state.vCode); let multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(cred); // Complete enrollment. this.props.user.multiFactor.multiFactor.enroll(multiFactorAssertion, this.props.user.userName); }).catch((error) => { console.error("Error adding multi-factor authentication: ", error); store.addNotification({ title: "Error", message: `Error adding multi-factor authentication: ${error}`, type: "danger", ...NOTIFICATION }) window.recaptchaVerifier.clear() });; }, 'expired-callback': () => { // Response expired. Ask user to solve reCAPTCHA again. store.addNotification({ title: "Timeout", message: `Please solve the reCAPTCHA again.`, type: "danger", ...NOTIFICATION }) window.recaptchaVerifier.clear() } }); window.recaptchaVerifier.render() } getAttemptedCode = async () => { } render() { if(this.state.loading.user){ return ( <Wrapper> <H2>Loading...</H2> </Wrapper> ) } else { return ( <Wrapper> <LLink to={`/admin/dashboard`}> <MdInvToPrimaryBtn type="button"> <i className="fas fa-chevron-left" /> Return to admin dashboard </MdInvToPrimaryBtn> </LLink> <H1>Admin Profile</H1> <Formik initialValues={{ firstName: this.state.user.firstName, lastName: this.state.user.lastName, email: this.state.user.email, phone: this.state.user.phone }} enableReinitialize={true} validationSchema={updateProfileSchema} onSubmit={(values, actions) => { //this.updateProfile(values); actions.resetForm(); }} > {props => ( <Form> <Grid fluid> <Row> <Col xs={12}> <Label htmlFor="phone">Phone: </Label> <SmText><RedText> <GreenHoverText onClick={() => this.setState({ editingPhone: true })}>update phone</GreenHoverText></RedText></SmText> <FField type="phone" disabled={true} onChange={props.handleChange} name="phone" value={props.values.phone} placeholder="(123) 456-7890" /> </Col> </Row> <Row center="xs"> <Col xs={12}> <MdGreenToInvBtn type="submit" disabled={!props.dirty && !props.isSubmitting}> Update </MdGreenToInvBtn> </Col> </Row> </Grid> </Form> )} </Formik> {this.state.editingPhone && ( <> <Hr/> <Formik initialValues={{ phone: this.state.user.phone }} enableReinitialize={true} validationSchema={updateProfilePhoneSchema} onSubmit={(values, actions) => { this.sendVerificationCode(values); }} > {props => ( <Form> <Grid fluid> <Row> <Col xs={12} sm={6}> <Label htmlFor="phone">Phone: </Label> <FField type="phone" onChange={props.handleChange} name="phone" value={props.values.phone} placeholder="(123) 456-7890" /> {props.errors.phone && props.touched.phone ? ( <RedText>{props.errors.phone}</RedText> ) : ( "" )} </Col> </Row> <Row center="xs"> <Col xs={12}> <MdGreenToInvBtn type="submit" disabled={!props.dirty && !props.isSubmitting}> Send verification code </MdGreenToInvBtn> </Col> </Row> </Grid> </Form> )} </Formik> </> )} {this.state.codeSent && ( <> <Formik initialValues={{ vCode: "" }} enableReinitialize={true} validationSchema={checkVCodeSchema} onSubmit={(values, actions) => { this.SetState({ vCode: values.vCode }); }} > {props => ( <Form> <Grid fluid> <Row> <FField type="text" onChange={props.handleChange} name="vCode" value={props.values.vCode} placeholder="abc123" /> {props.errors.vCode && props.touched.vCode ? ( <RedText>{props.errors.vCode}</RedText> ) : ( "" )} </Row> <Row center="xs"> <Col xs={12}> {/* TODO: add send code again button? */} <MdGreenToInvBtn type="submit" disabled={!props.dirty && !props.isSubmitting}> Submit verification code </MdGreenToInvBtn> </Col> </Row> </Grid> </Form> )} </Formik> </> )} <Recaptcha id="recaptcha" /> </Wrapper> ) } } } export default withRouter(AdminProfile);
Advertisement
Answer
Figured it out! I wrongly assumed that the verificationId
passed back from verifyPhoneNumber()
was the raw code and I didn’t want to save that in a local state on client side as I saw that as a security vulnerability. Fortunately the verificationId
is not the raw code to be entered, but rather a JWT or something that is abstracted, so I just saved that value in the React state which was then referenced by a separate function getAttemptedCode(values)
which is called only after the user clicks submit on the attempted code.
If anyone finds this method I found to be a security vulnerability let me know please!
Below is the updated MFA component, which has changed a bit since I made the original post, but should give the just of what I was trying to achieve!
export default function MfaSetup(props) { const [vCodeSent, setVCodeSent] = useState(false); const [verificationId, setVerificationId] = useState(null); const [enteredPhone, setEnteredPhone] = useState(null); const [submitting, setSubmitting] = useState({ vCode: false, }); const phoneForm = useForm({ defaultValues: { phone: "", } }); const vCodeForm = useForm({ defaultValues: { vCode: "", } }); const sendVCodeToNewPhone = (data) => { if(data.phone.substring(0, 2) !== "+1"){ // This validation only is valid for US numbers! toast.warn("Please reformat your phone number to international format such as: +1 234 567 8901"); } else { const recaptchaToastId = toast.info("Please complete the reCAPTCHA below to continue."); window.recaptchaVerifier = new RecaptchaVerifier("recaptcha", { "size": "normal", "callback": (response) => { const user = auth.currentUser; const mfaUser = multiFactor(user); mfaUser.getSession().then((multiFactorSession) => { // Specify the phone number and pass the MFA session. let phoneInfoOptions = { phoneNumber: data.phone, session: multiFactorSession }; let phoneAuthProvider = new PhoneAuthProvider(auth); // Send SMS verification code. phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, window.recaptchaVerifier).then((tempVerificationId) => { setVCodeSent(true); setVerificationId(tempVerificationId); setEnteredPhone(data.phone); toast.dismiss(recaptchaToastId); toast.success("We just sent that phone number a verification code, go grab the code and input it below!"); window.recaptchaVerifier.clear(); }).catch((error) => { console.error("Error adding phone: " + error); toast.error(`Error verifying phone with provider. Please try again or if the problem persists, contact ${props.site.emails.support}.`); toast.dismiss(recaptchaToastId); window.recaptchaVerifier.clear(); }); }).catch((error) => { console.error("Error adding multi-factor authentication: " + error); toast.error(`Error adding multi-factor authentication. Please try again or if the problem persists, contact ${props.site.emails.support}.`); toast.dismiss(recaptchaToastId); window.recaptchaVerifier.clear(); }); }, "expired-callback": () => { // Response expired. Ask user to solve reCAPTCHA again. toast.warn("Please solve the reCAPTCHA again!"); window.recaptchaVerifier.clear(); } }, auth); window.recaptchaVerifier.render(); } } const submitNewPhoneVCode = (data) => { setSubmitting(prevState => ({ ...prevState, vCode: true })); let cred = PhoneAuthProvider.credential(verificationId, data.vCode); let multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred); const user = auth.currentUser; const mfaUser = multiFactor(user); mfaUser.enroll(multiFactorAssertion, props.fireUser.userName).then(response => { if(props.user.phone){ // Unenroll old phone number if user has one mfaUser.unenroll(mfaUser.enrolledFactors[0]).then(() => { console.log("Successful removed old phone."); }).catch((error) => { console.error("Error removing old phone", error); }); } updateDoc(doc(firestore, "users", props.fireUser.uid), { phone: enteredPhone, }).then(() => { console.log("Successful updated user on Firestore."); }).catch((error) => { console.error("Error updating user document: " + error); toast.error(`Error updating user details. Please try again or if the problem persists, contact ${props.site.emails.support}.`); }); toast.success("Successfully updated phone number!"); props.setPhoneField("phone", enteredPhone); props.toggleModal(false, "mfa-setup"); setSubmitting(prevState => ({ ...prevState, vCode: false, })); setVCodeSent(false); }).catch(error => { console.error(`Error with entered code: ${error.message}`); toast.error(`Error with entered code. Please try again or if the problem persists, contact ${props.site.emails.support}.`); setSubmitting(prevState => ({ ...prevState, vCode: false, })); }); } return ( <> {!vCodeSent && ( <form onSubmit={ phoneForm.handleSubmit(sendVCodeToNewPhone) }> <Grid fluid> <Row justify="center"> <Column md={12} lg={8}> <Label htmlFor={INPUT.PHONE.KEY} br>Phone:</Label> <TextInput type="text" error={phoneForm.formState.errors[INPUT.PHONE.KEY]} placeholder={INPUT.PHONE.PLACEHOLDER} { ...phoneForm.register(INPUT.PHONE.KEY, { required: INPUT.PHONE.ERRORS.REQUIRED, } ) } /> <FormError error={phoneForm.formState.errors[INPUT.PHONE.KEY]} /> </Column> </Row> <Row> <Column md={12} textalign="center"> <Button type="submit" disabled={submitting.phone} > Submit </Button> </Column> </Row> <Row> <Column md={12} textalign="center"> <Body size={SIZES.SM}>This site is protected by reCAPTCHA and the <ALink target="_blank" rel="noopener" href="https://policies.google.com">Google Privacy Policy and Terms of Service</ALink> apply.</Body> <Recaptcha id="recaptcha" /> </Column> </Row> </Grid> </form> )} {vCodeSent && ( <form onSubmit={ vCodeForm.handleSubmit(submitNewPhoneVCode) }> <Grid fluid> <Row justify="center"> <Column md={12} lg={8}> <Label htmlFor={"vCode"} br>Enter the verification code sent to your phone number:</Label> <TextInput type="text" error={vCodeForm.formState.errors.vCode} placeholder={"12345"} { ...vCodeForm.register("vCode", { required: "You must enter the verification code sent to your phone number to continue.", } ) } /> <FormError error={vCodeForm.formState.errors.vCode} /> </Column> </Row> <Row> <Column md={12} textalign="center"> <Button type="submit" disabled={submitting.vCode} > Submit </Button> </Column> </Row> </Grid> </form> )} </> ) }