baumblatt / capacitor-firebase-auth

Capacitor Firebase Authentication Plugin
MIT License
261 stars 129 forks source link

Support for multi-factor authentication on iOS? #73

Open shanejearley opened 4 years ago

shanejearley commented 4 years ago

Has anyone used this plugin to implement Google Identity Platform's multi-factor authentication using the phone verification method for iOS?

It requires a combination of the web and iOS implementation documented starting here https://cloud.google.com/identity-platform/docs/web/mfa and here https://cloud.google.com/identity-platform/docs/ios/mfa.

For both enrolling a phone as a second factor, and signing a user in with an existing second factor, the iOS phone verification function requires three inputs. I was running into issues trying to change the number of inputs passed through the web implementation of this plugin to the iOS side, so instead I passed the necessary user information separated by spaces in the "phone" string and generated the necessary criteria on the iOS side. For example, for enrolling a second factor I passed the phone string as "phone+' '+email+' '+password" and for signing in with an existing second factor I passed the phone string as "email+' '+password".

Then, my PhoneNumberProvider.swift file looks like this:

import Foundation import Capacitor import FirebaseAuth

class PhoneNumberProviderHandler: NSObject, ProviderHandler {

var plugin: CapacitorFirebaseAuth? = nil
var mPhoneNumber: String? = nil
var mVerificationId: String? = nil
var mVerificationCode: String? = nil

func initialize(plugin: CapacitorFirebaseAuth) {
    print("Initializing Phone Number Provider Handler")
    self.plugin = plugin
}

func signIn(call: CAPPluginCall) {

    print("Sign in on iOS")

    guard let data = call.getObject("data") else {
        call.reject("The auth data is required")
        return
    }

    guard let phone = data["phone"] as? String else {
        call.reject("The phone number is required")
        return
    }

    self.mPhoneNumber = phone

    if phone.first == "+" {
        let components = phone.split{ $0.isWhitespace }
        let number = components[0]
        let email = components[1]
        let password = components[2]
        Auth.auth().signIn(withEmail: String(email), password: String(password)) { (result, error) in
            let authError = error as NSError?
            if authError != nil {
                call.reject("Email Sign In failure: \(String(describing: error))")
            } else {
                let user = Auth.auth().currentUser
                    user?.multiFactor.getSessionWithCompletion({ (session, error) in
                        PhoneAuthProvider.provider().verifyPhoneNumber(String(number), uiDelegate: nil, multiFactorSession: session) { (verificationID, error) in
                            if let error = error {
                                if let errCode = AuthErrorCode(rawValue: error._code) {
                                    switch errCode {
                                    case AuthErrorCode.quotaExceeded:
                                        call.reject("Quota exceeded.")
                                    case AuthErrorCode.invalidPhoneNumber:
                                        call.reject("Invalid phone number.")
                                    case AuthErrorCode.captchaCheckFailed:
                                        call.reject("Captcha Check Failed")
                                    case AuthErrorCode.missingPhoneNumber:
                                        call.reject("Missing phone number.")
                                    default:
                                        call.reject("PhoneAuth Sign In failure: \(String(describing: error))")
                                    }

                                    return
                                }
                            }

                            self.mVerificationId = verificationID

                            guard let verificationID = verificationID else {
                                call.reject("There is no verificationID after .verifyPhoneNumber!")
                                return
                            }

                            // notify event On Cond Sent.
                            self.plugin?.notifyListeners("cfaSignInPhoneOnCodeSent", data: ["verificationId" : verificationID ])

                            // return success call.
                            call.success([
                                "callbackId": call.callbackId,
                                "verificationId":verificationID
                            ]);

                        }
                    })
            }
        }

    } else {
        let components = phone.split{ $0.isWhitespace }
        let email = components[0]
        let password = components[1]
        Auth.auth().signIn(withEmail: String(email),
                           password: String(password)) { (result, error) in
          let authError = error as NSError?
          if (authError == nil || authError!.code != AuthErrorCode.secondFactorRequired.rawValue) {
            // User is not enrolled with a second factor and is successfully signed in.
            // ...
          } else {
            let resolver = authError!.userInfo[AuthErrorUserInfoMultiFactorResolverKey] as! MultiFactorResolver
            // Ask user which second factor to use.
            let hint = resolver.hints[0] as! PhoneMultiFactorInfo
            // Send SMS verification code
            PhoneAuthProvider.provider().verifyPhoneNumber(
              with: hint,
              uiDelegate: nil,
              multiFactorSession: resolver.session) { (verificationID, error) in
                if let error = error {
                    if let errCode = AuthErrorCode(rawValue: error._code) {
                        switch errCode {
                        case AuthErrorCode.quotaExceeded:
                            call.reject("Quota exceeded.")
                        case AuthErrorCode.invalidPhoneNumber:
                            call.reject("Invalid phone number.")
                        case AuthErrorCode.captchaCheckFailed:
                            call.reject("Captcha Check Failed")
                        case AuthErrorCode.missingPhoneNumber:
                            call.reject("Missing phone number.")
                        default:
                            call.reject("PhoneAuth Sign In failure: \(String(describing: error))")
                        }

                        return
                    }
                }

                self.mVerificationId = verificationID

                guard let verificationID = verificationID else {
                    call.reject("There is no verificationID after .verifyPhoneNumber!")
                    return
                }

                // notify event On Cond Sent.
                self.plugin?.notifyListeners("cfaSignInPhoneOnCodeSent", data: ["verificationId" : verificationID ])

                // return success call.
                call.success([
                    "callbackId": call.callbackId,
                    "verificationId":verificationID
                ]);

            }
          }
        }
    }
}

func signOut() {
    // do nothing
}

func isAuthenticated() -> Bool {
    return false
}

func fillResult(data: PluginResultData) -> PluginResultData {

    var jsResult: PluginResultData = [:]
    data.map { (key, value) in
        jsResult[key] = value
    }

    jsResult["phone"] = self.mPhoneNumber
    jsResult["verificationId"] = self.mVerificationId
    jsResult["verificationCode"] = self.mVerificationCode

    return jsResult

}

}

iamnels1 commented 3 years ago

A big thank you to you, I took a long time trying to implement multi-factor authentication without success and after multiple attempts I finally succeeded thanks to you.

I just want to mention that as I write these lines we need to replace:

var jsResult: PluginResultData = [:]
data.map { (key, value) in
    jsResult[key] = value
}

By

var jsResult: PluginResultData = [:]
data.forEach { (key, value) in
    jsResult[key] = value
}

Now I'll try to do the same for android. If anyone reads these lines and knows how to do that a helping hand would be welcome !!

Thank you @shanoinsano10 and thank you @baumblatt for this plugin really well done and whose complexity we measure when we dive into the code...

iamnels1 commented 3 years ago

This is what i used for android, it is not perfect but i am not able to do better. It works with version 2.3.1

package com.baumblatt.capacitor.firebase.auth.handlers;

import android.content.Intent; import android.util.Log;

import androidx.annotation.NonNull;

import com.baumblatt.capacitor.firebase.auth.CapacitorFirebaseAuth; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.Task; import com.google.firebase.FirebaseException; import com.google.firebase.FirebaseTooManyRequestsException; import com.google.firebase.auth.AuthCredential; import com.google.firebase.auth.AuthResult; import com.google.firebase.auth.FirebaseAuth; // added import com.google.firebase.auth.FirebaseAuth; import com.google.firebase.auth.FirebaseAuthMultiFactorException; import com.google.firebase.auth.FirebaseUser;

// added import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException; import com.google.firebase.auth.MultiFactorAssertion; import com.google.firebase.auth.MultiFactorInfo; import com.google.firebase.auth.MultiFactorResolver; import com.google.firebase.auth.MultiFactorSession; import com.google.firebase.auth.PhoneAuthCredential; import com.google.firebase.auth.PhoneAuthOptions; import com.google.firebase.auth.PhoneAuthProvider; import com.google.firebase.auth.PhoneMultiFactorGenerator; import com.google.firebase.auth.PhoneMultiFactorInfo;

import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit;

public class PhoneProviderHandler implements ProviderHandler { private static final String PHONE_TAG = "PhoneProviderHandler";

private String mVerificationId;
private String mVerificationCode;

private PhoneAuthProvider.ForceResendingToken mResendToken;
private PhoneAuthProvider.OnVerificationStateChangedCallbacks mCallbacks;

private CapacitorFirebaseAuth plugin;
private FirebaseAuth firebaseAuth;
private static final String TAG = "EmailPassword";

@Override
public void init(final CapacitorFirebaseAuth plugin) {
    this.plugin = plugin;

    this.mCallbacks = new PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
        @Override
        public void onVerificationCompleted(PhoneAuthCredential credential) {
            Log.d(PHONE_TAG, "PhoneAuth:onVerificationCompleted:" + credential);
            mVerificationCode = credential.getSmsCode();

            PluginCall call = plugin.getSavedCall();

            // Notify listeners of Code Received event.
            JSObject jsEvent = new JSObject();
            jsEvent.put("verificationId", mVerificationId);
            jsEvent.put("verificationCode", mVerificationCode);
            plugin.notifyListeners("cfaSignInPhoneOnCodeReceived", jsEvent);

            JSObject jsUser = new JSObject();
            jsUser.put("callbackId", call.getCallbackId());
            jsUser.put("providerId", credential.getProvider());
            jsUser.put("verificationId", mVerificationId);
            jsUser.put("verificationCode", mVerificationCode);

            call.success(jsUser);
        }

        @Override
        public void onVerificationFailed(FirebaseException error) {
            Log.w(PHONE_TAG, "PhoneAuth:onVerificationFailed:" + error);

            if (error instanceof FirebaseAuthInvalidCredentialsException) {
                plugin.handleFailure("Invalid phone number.", error);
            } else if (error instanceof FirebaseTooManyRequestsException) {
                plugin.handleFailure("Quota exceeded.", error);
            } else {
                plugin.handleFailure("PhoneAuth Sign In failure.", error);
            }

        }

        public void onCodeSent(String verificationId,
                               PhoneAuthProvider.ForceResendingToken token) {
            // The SMS verification code has been sent to the provided phone number, we
            // now need to ask the user to enter the code and then construct a credential
            // by combining the code with a verification ID.
            Log.d(PHONE_TAG, "onCodeSent:" + verificationId);

            // Save verification ID and resending token so we can use them later
            mVerificationId = verificationId;
            mResendToken = token;

            // Notify listeners of Code Sent event.
            JSObject jsEvent = new JSObject();
            jsEvent.put("verificationId", mVerificationId);
            plugin.notifyListeners("cfaSignInPhoneOnCodeSent", jsEvent);
        }
    };
}

@Override
public void signIn(PluginCall call) {
    if (!call.getData().has("data")) {
        call.reject("The auth data is required");
        return;
    }

    JSObject data = call.getObject("data", new JSObject());

    String phone = data.getString("phone", "");
    if (phone.equalsIgnoreCase("null") || phone.equalsIgnoreCase("")) {
        call.reject("The phone number is required");
        return;
    }

    if (phone.startsWith("+")) {
        String[] component = phone.split("\\s+");
        final String mNumber = component[0];
        Log.d( "phone", mNumber);
        String mEmail = component[1];
        Log.d( "email", mEmail);
        String mPassword = component[2];
        Log.d( "password", mPassword);

        String code = data.getString("verificationCode", "");
        if(code.equalsIgnoreCase("null") || code.equalsIgnoreCase("")) {
            // added
            this.firebaseAuth = FirebaseAuth.getInstance();
            // added
            firebaseAuth
            .signInWithEmailAndPassword(mEmail, mPassword)
                    .addOnCompleteListener(
                            new OnCompleteListener<AuthResult>() {
                        @Override
                        public void onComplete(@NonNull Task<AuthResult> task) {
                            if (task.isSuccessful()) {
                                // Sign in success, update UI with the signed-in user's information
                                Log.d(TAG, "signInWithEmail:success");
                                FirebaseUser user = firebaseAuth.getCurrentUser();
                                user.getMultiFactor().getSession()
                                        .addOnCompleteListener(
                                                new OnCompleteListener<MultiFactorSession>() {
                                                    @Override
                                                    public void onComplete(@NonNull Task<MultiFactorSession> task) {
                                                        if (task.isSuccessful()) {
                                                            MultiFactorSession multiFactorSession = task.getResult();
                                                            PhoneAuthOptions phoneAuthOptions =
                                                                    PhoneAuthOptions.newBuilder()
                                                                            .setPhoneNumber(mNumber)
                                                                            .setTimeout(30L, TimeUnit.SECONDS)
                                                                            .setMultiFactorSession(multiFactorSession)
                                                                            .setCallbacks(mCallbacks)
                                                                            .build();
                                                            // Send SMS verification code.
                                                            PhoneAuthProvider.verifyPhoneNumber(phoneAuthOptions);
                                                        }
                                                    }
                                                });

                            } else {
                                // If sign in fails, display a message to the user.
                                Log.w(TAG, "signInWithEmail:failure", task.getException());

                            }

                            // [START_EXCLUDE]
                            if (!task.isSuccessful()) {
                                Log.d(TAG, "signInWithEmail:success");
                            }

                            // [END_EXCLUDE]
                        }
                    });
            // [END sign_in_with_email]

        } else {
            AuthCredential credential = PhoneAuthProvider.getCredential(this.mVerificationId, code);
            this.mVerificationCode = code;
            plugin.handleAuthCredentials(credential);
        }

    } else {
        String[] component = phone.split("\\s+");
        String mEmail = component[0];
        Log.d( "email", mEmail);
        String mPassword = component[1];
        Log.d( "password", mPassword);

        String code = data.getString("verificationCode", "");
        if(code.equalsIgnoreCase("null") || code.equalsIgnoreCase("")) {
            // added
            this.firebaseAuth = FirebaseAuth.getInstance();
            // added
            firebaseAuth
                    .signInWithEmailAndPassword(mEmail, mPassword)
                    .addOnCompleteListener(
                            new OnCompleteListener<AuthResult>() {
                                @Override
                                public void onComplete(@NonNull Task<AuthResult> task) {
                                    if (task.isSuccessful()) {
                                        // User is not enrolled with a second factor and is successfully
                                        // signed in.
                                        // ...
                                        return;
                                    }

                                    // [START_EXCLUDE]
                                    if (task.getException() instanceof FirebaseAuthMultiFactorException) {
                                        FirebaseAuthMultiFactorException e =
                                                (FirebaseAuthMultiFactorException) task.getException();

                                        MultiFactorResolver multiFactorResolver = e.getResolver();
                                        if (multiFactorResolver.getHints().get(0).getFactorId()
                                                == PhoneMultiFactorGenerator.FACTOR_ID) {
                                            // User selected a phone second factor.
                                            MultiFactorInfo selectedHint =
                                                    multiFactorResolver.getHints().get(0);
                                            // Send the SMS verification code.
                                            // Send the SMS verification code.
                                            PhoneAuthProvider.verifyPhoneNumber(
                                                    PhoneAuthOptions.newBuilder()
                                                            .setMultiFactorHint((PhoneMultiFactorInfo) selectedHint)
                                                            .setMultiFactorSession(multiFactorResolver.getSession())
                                                            .setCallbacks(mCallbacks)
                                                            .setTimeout(30L, TimeUnit.SECONDS)
                                                            .build());
                                        } else {
                                            // Unsupported second factor.
                                            // Note that only phone second factors are currently supported.
                                        }

                                    }

                                    // [END_EXCLUDE]
                                }
                            });
            // [END sign_in_with_email]
        } else {
            AuthCredential credential = PhoneAuthProvider.getCredential(this.mVerificationId, code);
            this.mVerificationCode = code;
            plugin.handleAuthCredentials(credential);
        }
    }

}

@Override
public void signOut() {
    // there is nothing to do here
}

@Override
public int getRequestCode() {
    // there is nothing to do here
    return 0;
}

@Override
public void handleOnActivityResult(int requestCode, int resultCode, Intent data) {
    // there is nothing to do here
}

@Override
public boolean isAuthenticated() {
    return false;
}

@Override
public void fillResult(AuthCredential auth, JSObject jsUser) {
    jsUser.put("verificationId", this.mVerificationId);
    jsUser.put("verificationCode", this.mVerificationCode);

    this.mVerificationId = null;
    this.mVerificationCode = null;
}

}