Repository: firebase/firebase-admin-go
Pull request #511 (2022-09-14 17:40:10)

feat(auth): MFA Support for GoLang createUser, updateUser

lahirumaramba: Adding @kevinthecheung to review the reference doc changes. Thank you!
brokeyourbike: Hi @lahirumaramba @kevinthecheung , when do you think this can be merged?

Patch:
diff --git a/auth/user_mgt.go b/auth/user_mgt.go
index 333416e..8926e24 100644
--- a/auth/user_mgt.go
+++ b/auth/user_mgt.go
@@ -39,6 +39,9 @@ const (
 
 	// Maximum number of users allowed to batch delete at a time.
 	maxDeleteAccountsBatchSize = 1000
+	createUserMethod           = "createUser"
+	updateUserMethod           = "updateUser"
+	phoneMultiFactorID         = "phone"
 )
 
 // 'REDACTED', encoded as a base64 string.
@@ -66,6 +69,7 @@ type multiFactorInfoResponse struct {
 }
 
 // MultiFactorInfo describes a user enrolled second phone factor.
+// TODO : convert PhoneNumber to PhoneMultiFactorInfo struct
 type MultiFactorInfo struct {
 	UID                 string
 	DisplayName         string
@@ -147,6 +151,11 @@ func (u *UserToCreate) UID(uid string) *UserToCreate {
 	return u.set("localId", uid)
 }
 
+// MFASettings setter.
+func (u *UserToCreate) MFASettings(mfaSettings MultiFactorSettings) *UserToCreate {
+	return u.set("mfaSettings", mfaSettings)
+}
+
 func (u *UserToCreate) set(key string, value interface{}) *UserToCreate {
 	if u.params == nil {
 		u.params = make(map[string]interface{})
@@ -155,10 +164,34 @@ func (u *UserToCreate) set(key string, value interface{}) *UserToCreate {
 	return u
 }
 
+// Converts a client format second factor object to server format.
+func convertMultiFactorInfoToServerFormat(mfaInfo MultiFactorInfo) (multiFactorInfoResponse, error) {
+	var authFactorInfo multiFactorInfoResponse
+	if mfaInfo.EnrollmentTimestamp != 0 {
+		authFactorInfo.EnrolledAt = time.Unix(mfaInfo.EnrollmentTimestamp, 0).Format("2006-01-02T15:04:05Z07:00Z")
+	}
+	if mfaInfo.FactorID == phoneMultiFactorID {
+		authFactorInfo.PhoneInfo = mfaInfo.PhoneNumber
+		authFactorInfo.DisplayName = mfaInfo.DisplayName
+		authFactorInfo.MFAEnrollmentID = mfaInfo.UID
+		return authFactorInfo, nil
+	}
+	out, _ := json.Marshal(mfaInfo)
+	return multiFactorInfoResponse{}, fmt.Errorf("Unsupported second factor %s provided", string(out))
+}
+
 func (u *UserToCreate) validatedRequest() (map[string]interface{}, error) {
 	req := make(map[string]interface{})
 	for k, v := range u.params {
-		req[k] = v
+		if k == "mfaSettings" {
+			mfaInfo, err := validateAndFormatMfaSettings(v.(MultiFactorSettings), createUserMethod)
+			if err != nil {
+				return nil, err
+			}
+			req["mfaInfo"] = mfaInfo
+		} else {
+			req[k] = v
+		}
 	}
 
 	if uid, ok := req["localId"]; ok {
@@ -191,7 +224,6 @@ func (u *UserToCreate) validatedRequest() (map[string]interface{}, error) {
 			return nil, err
 		}
 	}
-
 	return req, nil
 }
 
@@ -241,6 +273,11 @@ func (u *UserToUpdate) PhotoURL(url string) *UserToUpdate {
 	return u.set("photoUrl", url)
 }
 
+// MFASettings setter.
+func (u *UserToUpdate) MFASettings(mfaSettings MultiFactorSettings) *UserToUpdate {
+	return u.set("mfaSettings", mfaSettings)
+}
+
 // ProviderToLink links this user to the specified provider.
 //
 // Linking a provider to an existing user account does not invalidate the
@@ -291,7 +328,15 @@ func (u *UserToUpdate) validatedRequest() (map[string]interface{}, error) {
 
 	req := make(map[string]interface{})
 	for k, v := range u.params {
-		req[k] = v
+		if k == "mfaSettings" {
+			mfaInfo, err := validateAndFormatMfaSettings(v.(MultiFactorSettings), updateUserMethod)
+			if err != nil {
+				return nil, err
+			}
+			req["mfaInfo"] = mfaInfo
+		} else {
+			req[k] = v
+		}
 	}
 
 	if email, ok := req["email"]; ok {
@@ -604,6 +649,45 @@ func validateProvider(providerID string, providerUID string) error {
 	return nil
 }
 
+func validateAndFormatMfaSettings(mfaSettings MultiFactorSettings, methodType string) ([]*multiFactorInfoResponse, error) {
+	var mfaInfo []*multiFactorInfoResponse
+	for _, multiFactorInfo := range mfaSettings.EnrolledFactors {
+		if multiFactorInfo.FactorID == "" {
+			return nil, fmt.Errorf("no factor id specified")
+		}
+		switch methodType {
+		case createUserMethod:
+			// Enrollment time and uid are not allowed for signupNewUser endpoint. They will automatically be provisioned server side.
+			if multiFactorInfo.EnrollmentTimestamp != 0 {
+				return nil, fmt.Errorf("\"EnrollmentTimeStamp\" is not supported when adding second factors via \"createUser()\"")
+			}
+			if multiFactorInfo.UID != "" {
+				return nil, fmt.Errorf("\"uid\" is not supported when adding second factors via \"createUser()\"")
+			}
+		case updateUserMethod:
+			if multiFactorInfo.UID == "" {
+				return nil, fmt.Errorf("the second factor \"uid\" must be a valid non-empty string when adding second factors via \"updateUser()\"")
+			}
+		default:
+			return nil, fmt.Errorf("unsupported methodType: %s", methodType)
+		}
+		if err := validateDisplayName(multiFactorInfo.DisplayName); err != nil {
+			return nil, fmt.Errorf("the second factor \"displayName\" for \"%s\" must be a valid non-empty string", multiFactorInfo.DisplayName)
+		}
+		if multiFactorInfo.FactorID == phoneMultiFactorID {
+			if err := validatePhone(multiFactorInfo.PhoneNumber); err != nil {
+				return nil, fmt.Errorf("the second factor \"phoneNumber\" for \"%s\" must be a non-empty E.164 standard compliant identifier string", multiFactorInfo.PhoneNumber)
+			}
+		}
+		obj, err := convertMultiFactorInfoToServerFormat(*multiFactorInfo)
+		if err != nil {
+			return nil, err
+		}
+		mfaInfo = append(mfaInfo, &obj)
+	}
+	return mfaInfo, nil
+}
+
 // End of validators
 
 // GetUser gets the user data corresponding to the specified user ID.
@@ -999,7 +1083,7 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
 			UID:                 factor.MFAEnrollmentID,
 			DisplayName:         factor.DisplayName,
 			EnrollmentTimestamp: enrollmentTimestamp,
-			FactorID:            "phone",
+			FactorID:            phoneMultiFactorID,
 			PhoneNumber:         factor.PhoneInfo,
 		})
 	}
diff --git a/go.sum b/go.sum
index 45c737e..8a4e74e 100644
--- a/go.sum
+++ b/go.sum
@@ -250,6 +250,7 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu
 golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug=
 golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=


Test patch:
diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go
index 17b0f10..aa0f465 100644
--- a/auth/user_mgt_test.go
+++ b/auth/user_mgt_test.go
@@ -642,6 +642,62 @@ func TestInvalidCreateUser(t *testing.T) {
 		}, {
 			(&UserToCreate{}).Email("a@a@a"),
 			`malformed email string: "a@a@a"`,
+		}, {
+			(&UserToCreate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						UID:         "EnrollmentID",
+						PhoneNumber: "+11234567890",
+						DisplayName: "Spouse's phone number",
+						FactorID:    "phone",
+					},
+				},
+			}),
+			`"uid" is not supported when adding second factors via "createUser()"`,
+		}, {
+			(&UserToCreate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						PhoneNumber: "invalid",
+						DisplayName: "Spouse's phone number",
+						FactorID:    "phone",
+					},
+				},
+			}),
+			`the second factor "phoneNumber" for "invalid" must be a non-empty E.164 standard compliant identifier string`,
+		}, {
+			(&UserToCreate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						PhoneNumber:         "+11234567890",
+						DisplayName:         "Spouse's phone number",
+						FactorID:            "phone",
+						EnrollmentTimestamp: time.Now().UTC().Unix(),
+					},
+				},
+			}),
+			`"EnrollmentTimeStamp" is not supported when adding second factors via "createUser()"`,
+		}, {
+			(&UserToCreate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						PhoneNumber: "+11234567890",
+						DisplayName: "Spouse's phone number",
+						FactorID:    "",
+					},
+				},
+			}),
+			`no factor id specified`,
+		}, {
+			(&UserToCreate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						PhoneNumber: "+11234567890",
+						FactorID:    "phone",
+					},
+				},
+			}),
+			`the second factor "displayName" for "" must be a valid non-empty string`,
 		},
 	}
 	client := &Client{
@@ -713,6 +769,49 @@ var createUserCases = []struct {
 	{
 		(&UserToCreate{}).PhotoURL("http://some.url"),
 		map[string]interface{}{"photoUrl": "http://some.url"},
+	}, {
+		(&UserToCreate{}).MFASettings(MultiFactorSettings{
+			EnrolledFactors: []*MultiFactorInfo{
+				{
+					PhoneNumber: "+11234567890",
+					DisplayName: "Spouse's phone number",
+					FactorID:    "phone",
+				},
+			},
+		}),
+		map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{
+			{
+				PhoneInfo:   "+11234567890",
+				DisplayName: "Spouse's phone number",
+			},
+		},
+		},
+	}, {
+		(&UserToCreate{}).MFASettings(MultiFactorSettings{
+			EnrolledFactors: []*MultiFactorInfo{
+				{
+					PhoneNumber: "+11234567890",
+					DisplayName: "number1",
+					FactorID:    "phone",
+				},
+				{
+					PhoneNumber: "+11234567890",
+					DisplayName: "number2",
+					FactorID:    "phone",
+				},
+			},
+		}),
+		map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{
+			{
+				PhoneInfo:   "+11234567890",
+				DisplayName: "number1",
+			},
+			{
+				PhoneInfo:   "+11234567890",
+				DisplayName: "number2",
+			},
+		},
+		},
 	},
 }
 
@@ -772,6 +871,40 @@ func TestInvalidUpdateUser(t *testing.T) {
 		}, {
 			(&UserToUpdate{}).Password("short"),
 			"password must be a string at least 6 characters long",
+		}, {
+			(&UserToUpdate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						UID:         "enrolledSecondFactor1",
+						PhoneNumber: "+11234567890",
+						FactorID:    "phone",
+					},
+				},
+			}),
+			`the second factor "displayName" for "" must be a valid non-empty string`,
+		}, {
+			(&UserToUpdate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						UID:         "enrolledSecondFactor1",
+						PhoneNumber: "invalid",
+						DisplayName: "Spouse's phone number",
+						FactorID:    "phone",
+					},
+				},
+			}),
+			`the second factor "phoneNumber" for "invalid" must be a non-empty E.164 standard compliant identifier string`,
+		}, {
+			(&UserToUpdate{}).MFASettings(MultiFactorSettings{
+				EnrolledFactors: []*MultiFactorInfo{
+					{
+						PhoneNumber: "+11234567890",
+						FactorID:    "phone",
+						DisplayName: "Spouse's phone number",
+					},
+				},
+			}),
+			`the second factor "uid" must be a valid non-empty string when adding second factors via "updateUser()"`,
 		}, {
 			(&UserToUpdate{}).ProviderToLink(&UserProvider{UID: "google_uid"}),
 			"user provider must specify a provider ID",
@@ -912,6 +1045,42 @@ var updateUserCases = []struct {
 			"deleteProvider":  []string{"phone"},
 		},
 	},
+	{
+		(&UserToUpdate{}).MFASettings(MultiFactorSettings{
+			EnrolledFactors: []*MultiFactorInfo{
+				{
+					UID:                 "enrolledSecondFactor1",
+					PhoneNumber:         "+11234567890",
+					DisplayName:         "Spouse's phone number",
+					FactorID:            "phone",
+					EnrollmentTimestamp: time.Now().Unix(),
+				}, {
+					UID:         "enrolledSecondFactor2",
+					PhoneNumber: "+11234567890",
+					DisplayName: "Spouse's phone number",
+					FactorID:    "phone",
+				},
+			},
+		}),
+		map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{
+			{
+				MFAEnrollmentID: "enrolledSecondFactor1",
+				PhoneInfo:       "+11234567890",
+				DisplayName:     "Spouse's phone number",
+				EnrolledAt:      time.Now().Format("2006-01-02T15:04:05Z07:00Z"),
+			},
+			{
+				MFAEnrollmentID: "enrolledSecondFactor2",
+				DisplayName:     "Spouse's phone number",
+				PhoneInfo:       "+11234567890",
+			},
+		},
+		},
+	},
+	{
+		(&UserToUpdate{}).MFASettings(MultiFactorSettings{}),
+		map[string]interface{}{"mfaInfo": nil},
+	},
 	{
 		(&UserToUpdate{}).ProviderToLink(&UserProvider{
 			ProviderID: "google.com",
diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go
index 71867be..9b567d1 100644
--- a/integration/auth/user_mgt_test.go
+++ b/integration/auth/user_mgt_test.go
@@ -427,6 +427,72 @@ func TestCreateUser(t *testing.T) {
 	}
 }
 
+func TestCreateUserMFA(t *testing.T) {
+	var tc *auth.UserToCreate = &auth.UserToCreate{}
+	tc.Email("testuser@example.com")
+	tc.EmailVerified(true)
+	tc.MFASettings(auth.MultiFactorSettings{
+		EnrolledFactors: []*auth.MultiFactorInfo{
+			{
+				PhoneNumber: "+11234567890",
+				DisplayName: "Spouse's phone number",
+				FactorID:    "phone",
+			},
+		},
+	})
+	user, err := client.CreateUser(context.Background(), tc)
+	if err != nil {
+		t.Fatalf("CreateUser() = %v; want = nil", err)
+	}
+	uidToDelete := user.UID
+	defer deleteUser(uidToDelete)
+	var factor []*auth.MultiFactorInfo = []*auth.MultiFactorInfo{
+		{
+			UID:                 user.MultiFactor.EnrolledFactors[0].UID,
+			DisplayName:         "Spouse's phone number",
+			FactorID:            "phone",
+			PhoneNumber:         "+11234567890",
+			EnrollmentTimestamp: user.MultiFactor.EnrolledFactors[0].EnrollmentTimestamp,
+		},
+	}
+	want := auth.UserRecord{
+		EmailVerified: true,
+		UserInfo: &auth.UserInfo{
+			Email:      "testuser@example.com",
+			UID:        user.UID,
+			ProviderID: "firebase",
+		},
+		UserMetadata: &auth.UserMetadata{
+			CreationTimestamp: user.UserMetadata.CreationTimestamp,
+		},
+		TokensValidAfterMillis: user.TokensValidAfterMillis,
+		MultiFactor: &auth.MultiFactorSettings{
+			EnrolledFactors: factor,
+		},
+	}
+	if !reflect.DeepEqual(*user, want) {
+		t.Errorf("CreateUser() = %#v; want = %#v", *user, want)
+	}
+	factor = []*auth.MultiFactorInfo{}
+	user, err = client.CreateUser(context.Background(), (&auth.UserToCreate{}).UID(user.UID))
+	want = auth.UserRecord{
+		UserInfo: &auth.UserInfo{
+			UID:        user.UID,
+			ProviderID: "firebase",
+		},
+		UserMetadata: &auth.UserMetadata{
+			CreationTimestamp: user.UserMetadata.CreationTimestamp,
+		},
+		TokensValidAfterMillis: user.TokensValidAfterMillis,
+		MultiFactor: &auth.MultiFactorSettings{
+			EnrolledFactors: factor,
+		},
+	}
+	if err == nil || user != nil || !auth.IsUIDAlreadyExists(err) {
+		t.Errorf("CreateUser(existing-uid) = (%#v, %v); want = (%#v, error)", user, err, want)
+	}
+}
+
 func TestUpdateUser(t *testing.T) {
 	// Creates a new user for testing purposes. The user's uid will be
 	// '$name_$tenRandomChars' and email will be