Skip to content
Advertisement

MFA Firebase & React Flow

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" />&nbsp; 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>
        )}
        </>
    )
}
User contributions licensed under: CC BY-SA
10 People found this is helpful
Advertisement