Repository: firebase/firebase-admin-go
Pull request #422 (2021-03-09 10:47:22)

feat(auth): Add MFA info to UserRecord
### Discussion

Issue: https://github.com/firebase/firebase-admin-go/issues/421

This PR implements a small part of Multi Factor Authentication (MFA). I do realize this isn't a full implementation, unfortunately I don't have time for that. This PR only implements unmarshalling the MFA info from the REST response into the `UserRecord` struct.

For structure and naming within the `UserRecord`, I've decided to be consistent with the Node.js client library, which already has this implemented: https://firebase.google.com/docs/reference/admin/node/admin.auth.UserRecord

### Testing

  * I've updated the existing tests to also incorporate MFA. On the existing `testUser`, I've added MFA info. Next to that, I've introduced a test user without MFA. In the list endpoints, I replaced every third user with a non MFA user, so the list tests both users with and without MFA info available. All unit tests should be OK.
  * Unfortunately, I wasn't able to get the integration tests running locally, so I'm not sure whether I did break something there. I did manage to test the code in an actual project with a small local setup and verified that it works.

### API Changes

  * This PR does not change the public API. The REST API already returns the `mfaInfo` section. This PR merely adds the code to pick it up into Golang structs.

bojeil-google: > Thanks @jasperkuperus for the PR. It looks mostly good. I've suggested some changes to the public API.
> 
> @bojeil-google how do you feel about just releasing the changes to `UserRecord` without the support for updating MFA settings?

I think it's fine to do so, as long as we keep track of the missing functionality (updating MFA settings). Ultimately, we want to make sure we have feature parity across all languages.
hiranya911: API review process for this has been initiated. Should have some updates by next week.
hiranya911: This API has been approved with some minor changes. The change being removal of the `MultiFactorID` type and the related `Phone` constant (we will use `FactorID string` instead).

@jasperkuperus would you like to push the change to this PR? If you're busy I can merge this as-is, and implement the required changes on top of that.
jasperkuperus: > @jasperkuperus would you like to push the change to this PR? If you're busy I can merge this as-is, and implement the required changes on top of that.

@hiranya911  It's a small change, but I have a rather busy week for me. If you could be so kind to merge it and implement the change on top of that, it'd be of great help, thanks! 😊 

Patch:
diff --git a/auth/user_mgt.go b/auth/user_mgt.go
index c44e15a..b150447 100644
--- a/auth/user_mgt.go
+++ b/auth/user_mgt.go
@@ -57,6 +57,37 @@ type UserInfo struct {
 	UID        string `json:"rawId,omitempty"`
 }
 
+// multiFactorInfoResponse describes the `mfaInfo` of the user record API response
+type multiFactorInfoResponse struct {
+	MFAEnrollmentID string `json:"mfaEnrollmentId,omitempty"`
+	DisplayName     string `json:"displayName,omitempty"`
+	PhoneInfo       string `json:"phoneInfo,omitempty"`
+	EnrolledAt      string `json:"enrolledAt,omitempty"`
+}
+
+// MultiFactorID represents the type of an enrolled factor, for now only Phone
+// is available.
+type MultiFactorID string
+
+const (
+	// Phone represents an enrolled factor of type Phone / SMS
+	Phone MultiFactorID = "phone"
+)
+
+// MultiFactorInfo describes a user enrolled second phone factor.
+type MultiFactorInfo struct {
+	UID                 string
+	DisplayName         string
+	EnrollmentTimestamp int64
+	FactorID            MultiFactorID
+	PhoneNumber         string
+}
+
+// MultiFactorSettings describes the multi-factor related user settings.
+type MultiFactorSettings struct {
+	EnrolledFactors []*MultiFactorInfo
+}
+
 // UserMetadata contains additional metadata associated with a user account.
 // Timestamps are in milliseconds since epoch.
 type UserMetadata struct {
@@ -77,6 +108,7 @@ type UserRecord struct {
 	TokensValidAfterMillis int64 // milliseconds since epoch.
 	UserMetadata           *UserMetadata
 	TenantID               string
+	MultiFactor            *MultiFactorSettings
 }
 
 // UserToCreate is the parameter struct for the CreateUser function.
@@ -892,23 +924,24 @@ func (c *baseClient) GetUsers(
 }
 
 type userQueryResponse struct {
-	UID                string      `json:"localId,omitempty"`
-	DisplayName        string      `json:"displayName,omitempty"`
-	Email              string      `json:"email,omitempty"`
-	PhoneNumber        string      `json:"phoneNumber,omitempty"`
-	PhotoURL           string      `json:"photoUrl,omitempty"`
-	CreationTimestamp  int64       `json:"createdAt,string,omitempty"`
-	LastLogInTimestamp int64       `json:"lastLoginAt,string,omitempty"`
-	LastRefreshAt      string      `json:"lastRefreshAt,omitempty"`
-	ProviderID         string      `json:"providerId,omitempty"`
-	CustomAttributes   string      `json:"customAttributes,omitempty"`
-	Disabled           bool        `json:"disabled,omitempty"`
-	EmailVerified      bool        `json:"emailVerified,omitempty"`
-	ProviderUserInfo   []*UserInfo `json:"providerUserInfo,omitempty"`
-	PasswordHash       string      `json:"passwordHash,omitempty"`
-	PasswordSalt       string      `json:"salt,omitempty"`
-	TenantID           string      `json:"tenantId,omitempty"`
-	ValidSinceSeconds  int64       `json:"validSince,string,omitempty"`
+	UID                string                     `json:"localId,omitempty"`
+	DisplayName        string                     `json:"displayName,omitempty"`
+	Email              string                     `json:"email,omitempty"`
+	PhoneNumber        string                     `json:"phoneNumber,omitempty"`
+	PhotoURL           string                     `json:"photoUrl,omitempty"`
+	CreationTimestamp  int64                      `json:"createdAt,string,omitempty"`
+	LastLogInTimestamp int64                      `json:"lastLoginAt,string,omitempty"`
+	LastRefreshAt      string                     `json:"lastRefreshAt,omitempty"`
+	ProviderID         string                     `json:"providerId,omitempty"`
+	CustomAttributes   string                     `json:"customAttributes,omitempty"`
+	Disabled           bool                       `json:"disabled,omitempty"`
+	EmailVerified      bool                       `json:"emailVerified,omitempty"`
+	ProviderUserInfo   []*UserInfo                `json:"providerUserInfo,omitempty"`
+	PasswordHash       string                     `json:"passwordHash,omitempty"`
+	PasswordSalt       string                     `json:"salt,omitempty"`
+	TenantID           string                     `json:"tenantId,omitempty"`
+	ValidSinceSeconds  int64                      `json:"validSince,string,omitempty"`
+	MFAInfo            []*multiFactorInfoResponse `json:"mfaInfo,omitempty"`
 }
 
 func (r *userQueryResponse) makeUserRecord() (*UserRecord, error) {
@@ -948,6 +981,28 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
 		lastRefreshTimestamp = t.Unix() * 1000
 	}
 
+	// Map the MFA info to a slice of enrolled factors. Currently there is only
+	// support for PhoneMultiFactorInfo.
+	var enrolledFactors []*MultiFactorInfo
+	for _, factor := range r.MFAInfo {
+		var enrollmentTimestamp int64
+		if factor.EnrolledAt != "" {
+			t, err := time.Parse(time.RFC3339, factor.EnrolledAt)
+			if err != nil {
+				return nil, err
+			}
+			enrollmentTimestamp = t.Unix() * 1000
+		}
+
+		enrolledFactors = append(enrolledFactors, &MultiFactorInfo{
+			UID:                 factor.MFAEnrollmentID,
+			DisplayName:         factor.DisplayName,
+			EnrollmentTimestamp: enrollmentTimestamp,
+			FactorID:            Phone,
+			PhoneNumber:         factor.PhoneInfo,
+		})
+	}
+
 	return &ExportedUserRecord{
 		UserRecord: &UserRecord{
 			UserInfo: &UserInfo{
@@ -969,6 +1024,9 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error
 				CreationTimestamp:    r.CreationTimestamp,
 				LastRefreshTimestamp: lastRefreshTimestamp,
 			},
+			MultiFactor: &MultiFactorSettings{
+				EnrolledFactors: enrolledFactors,
+			},
 		},
 		PasswordHash: hash,
 		PasswordSalt: r.PasswordSalt,


Test patch:
diff --git a/auth/tenant_mgt_test.go b/auth/tenant_mgt_test.go
index c5d6b34..c6ce090 100644
--- a/auth/tenant_mgt_test.go
+++ b/auth/tenant_mgt_test.go
@@ -164,7 +164,7 @@ func TestTenantListUsers(t *testing.T) {
 	want := []*ExportedUserRecord{
 		{UserRecord: testUser, PasswordHash: "passwordhash1", PasswordSalt: "salt1"},
 		{UserRecord: testUser, PasswordHash: "passwordhash2", PasswordSalt: "salt2"},
-		{UserRecord: testUser, PasswordHash: "passwordhash3", PasswordSalt: "salt3"},
+		{UserRecord: testUserWithoutMFA, PasswordHash: "passwordhash3", PasswordSalt: "salt3"},
 	}
 
 	testIterator := func(iter *UserIterator, token string, req string) {
diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go
index 78ad58d..076816e 100644
--- a/auth/user_mgt_test.go
+++ b/auth/user_mgt_test.go
@@ -67,6 +67,55 @@ var testUser = &UserRecord{
 	},
 	CustomClaims: map[string]interface{}{"admin": true, "package": "gold"},
 	TenantID:     "testTenant",
+	MultiFactor: &MultiFactorSettings{
+		EnrolledFactors: []*MultiFactorInfo{
+			{
+				UID:                 "0aaded3f-5e73-461d-aef9-37b48e3769be",
+				FactorID:            Phone,
+				EnrollmentTimestamp: 1614776780000,
+				PhoneNumber:         "+1234567890",
+				DisplayName:         "My MFA Phone",
+			},
+		},
+	},
+}
+
+var emptyFactors []*MultiFactorInfo
+var testUserWithoutMFA = &UserRecord{
+	UserInfo: &UserInfo{
+		UID:         "testusernomfa",
+		Email:       "testusernomfa@example.com",
+		PhoneNumber: "+1234567890",
+		DisplayName: "Test User Without MFA",
+		PhotoURL:    "http://www.example.com/testusernomfa/photo.png",
+		ProviderID:  defaultProviderID,
+	},
+	Disabled: false,
+
+	EmailVerified: true,
+	ProviderUserInfo: []*UserInfo{
+		{
+			ProviderID:  "password",
+			DisplayName: "Test User Without MFA",
+			PhotoURL:    "http://www.example.com/testusernomfa/photo.png",
+			Email:       "testusernomfa@example.com",
+			UID:         "testuid",
+		}, {
+			ProviderID:  "phone",
+			PhoneNumber: "+1234567890",
+			UID:         "testuid",
+		},
+	},
+	TokensValidAfterMillis: 1494364393000,
+	UserMetadata: &UserMetadata{
+		CreationTimestamp:  1234567890000,
+		LastLogInTimestamp: 1233211232000,
+	},
+	CustomClaims: map[string]interface{}{"admin": true, "package": "gold"},
+	TenantID:     "testTenant",
+	MultiFactor: &MultiFactorSettings{
+		EnrolledFactors: emptyFactors,
+	},
 }
 
 func TestGetUser(t *testing.T) {
@@ -501,7 +550,7 @@ func TestListUsers(t *testing.T) {
 	want := []*ExportedUserRecord{
 		{UserRecord: testUser, PasswordHash: "passwordhash1", PasswordSalt: "salt1"},
 		{UserRecord: testUser, PasswordHash: "passwordhash2", PasswordSalt: "salt2"},
-		{UserRecord: testUser, PasswordHash: "passwordhash3", PasswordSalt: "salt3"},
+		{UserRecord: testUserWithoutMFA, PasswordHash: "passwordhash3", PasswordSalt: "salt3"},
 	}
 
 	testIterator := func(iter *UserIterator, token string, req string) {
@@ -1596,6 +1645,14 @@ func TestMakeExportedUser(t *testing.T) {
 				PhoneNumber: "+1234567890",
 				UID:         "testuid",
 			}},
+		MFAInfo: []*multiFactorInfoResponse{
+			{
+				PhoneInfo:       "+1234567890",
+				MFAEnrollmentID: "0aaded3f-5e73-461d-aef9-37b48e3769be",
+				DisplayName:     "My MFA Phone",
+				EnrolledAt:      "2021-03-03T13:06:20.542896Z",
+			},
+		},
 	}
 
 	want := &ExportedUserRecord{
diff --git a/testdata/get_user.json b/testdata/get_user.json
index a641cf1..a2cac48 100644
--- a/testdata/get_user.json
+++ b/testdata/get_user.json
@@ -31,7 +31,15 @@
       "createdAt": "1234567890000",
       "lastLoginAt": "1233211232000",
       "customAttributes": "{\"admin\": true, \"package\": \"gold\"}",
-      "tenantId": "testTenant"
+      "tenantId": "testTenant",
+      "mfaInfo": [
+        {
+          "phoneInfo": "+1234567890",
+          "mfaEnrollmentId": "0aaded3f-5e73-461d-aef9-37b48e3769be",
+          "displayName": "My MFA Phone",
+          "enrolledAt": "2021-03-03T13:06:20.542896Z"
+        }
+      ]
     }
   ]
 }
diff --git a/testdata/list_users.json b/testdata/list_users.json
index 3c61d48..bf94ff4 100644
--- a/testdata/list_users.json
+++ b/testdata/list_users.json
@@ -31,7 +31,15 @@
             "createdAt": "1234567890000",
             "lastLoginAt": "1233211232000",
             "customAttributes": "{\"admin\": true, \"package\": \"gold\"}",
-            "tenantId": "testTenant"
+            "tenantId": "testTenant",
+            "mfaInfo": [
+              {
+                "phoneInfo": "+1234567890",
+                "mfaEnrollmentId": "0aaded3f-5e73-461d-aef9-37b48e3769be",
+                "displayName": "My MFA Phone",
+                "enrolledAt": "2021-03-03T13:06:20.542896Z"
+              }
+            ]
         },
         {
             "localId": "testuser",
@@ -63,21 +71,29 @@
             "createdAt": "1234567890000",
             "lastLoginAt": "1233211232000",
             "customAttributes": "{\"admin\": true, \"package\": \"gold\"}",
-            "tenantId": "testTenant"
+            "tenantId": "testTenant",
+            "mfaInfo": [
+              {
+                "phoneInfo": "+1234567890",
+                "mfaEnrollmentId": "0aaded3f-5e73-461d-aef9-37b48e3769be",
+                "displayName": "My MFA Phone",
+                "enrolledAt": "2021-03-03T13:06:20.542896Z"
+              }
+            ]
         },
         {
-            "localId": "testuser",
-            "email": "testuser@example.com",
+            "localId": "testusernomfa",
+            "email": "testusernomfa@example.com",
             "phoneNumber": "+1234567890",
             "emailVerified": true,
-            "displayName": "Test User",
+            "displayName": "Test User Without MFA",
             "providerUserInfo": [
                 {
                     "providerId": "password",
-                    "displayName": "Test User",
-                    "photoUrl": "http://www.example.com/testuser/photo.png",
-                    "federatedId": "testuser@example.com",
-                    "email": "testuser@example.com",
+                    "displayName": "Test User Without MFA",
+                    "photoUrl": "http://www.example.com/testusernomfa/photo.png",
+                    "federatedId": "testusernomfa@example.com",
+                    "email": "testusernomfa@example.com",
                     "rawId": "testuid"
                 },
                 {
@@ -86,7 +102,7 @@
                     "rawId": "testuid"
                 }
             ],
-            "photoUrl": "http://www.example.com/testuser/photo.png",
+            "photoUrl": "http://www.example.com/testusernomfa/photo.png",
             "passwordHash": "passwordhash3",
             "salt": "salt3",
             "passwordUpdatedAt": 1.494364393E+12,