diff --git a/auth/user_mgt.go b/auth/user_mgt.go index 338a976c..601c1e03 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -42,6 +42,7 @@ const ( createUserMethod = "createUser" updateUserMethod = "updateUser" phoneMultiFactorID = "phone" + totpMultiFactorID = "totp" ) // 'REDACTED', encoded as a base64 string. @@ -62,24 +63,37 @@ type UserInfo struct { // 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"` + MFAEnrollmentID string `json:"mfaEnrollmentId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PhoneInfo string `json:"phoneInfo,omitempty"` + TOTPInfo *TOTPInfo `json:"totpInfo,omitempty"` + EnrolledAt string `json:"enrolledAt,omitempty"` } +// TOTPInfo describes a user enrolled second TOTP factor. +type TOTPInfo struct{} + +// PhoneMultiFactorInfo describes a user enrolled in SMS second factor. +type PhoneMultiFactorInfo struct { + PhoneNumber string +} + +// TOTPMultiFactorInfo describes a user enrolled in TOTP second factor. +type TOTPMultiFactorInfo struct{} + type multiFactorEnrollments struct { Enrollments []*multiFactorInfoResponse `json:"enrollments"` } // MultiFactorInfo describes a user enrolled second phone factor. -// TODO : convert PhoneNumber to PhoneMultiFactorInfo struct type MultiFactorInfo struct { UID string DisplayName string EnrollmentTimestamp int64 FactorID string - PhoneNumber string + PhoneNumber string // Deprecated: Use PhoneMultiFactorInfo instead + Phone *PhoneMultiFactorInfo + TOTP *TOTPMultiFactorInfo } // MultiFactorSettings describes the multi-factor related user settings. @@ -177,12 +191,17 @@ func convertMultiFactorInfoToServerFormat(mfaInfo MultiFactorInfo) (multiFactorI if mfaInfo.UID != "" { authFactorInfo.MFAEnrollmentID = mfaInfo.UID } - if mfaInfo.FactorID == phoneMultiFactorID { - authFactorInfo.PhoneInfo = mfaInfo.PhoneNumber - return authFactorInfo, nil + + switch mfaInfo.FactorID { + case phoneMultiFactorID: + authFactorInfo.PhoneInfo = mfaInfo.Phone.PhoneNumber + case totpMultiFactorID: + authFactorInfo.TOTPInfo = (*TOTPInfo)(mfaInfo.TOTP) + default: + out, _ := json.Marshal(mfaInfo) + return multiFactorInfoResponse{}, fmt.Errorf("unsupported second factor %s provided", string(out)) } - out, _ := json.Marshal(mfaInfo) - return multiFactorInfoResponse{}, fmt.Errorf("unsupported second factor %s provided", string(out)) + return authFactorInfo, nil } func (u *UserToCreate) validatedRequest() (map[string]interface{}, error) { @@ -338,8 +357,7 @@ func (u *UserToUpdate) validatedRequest() (map[string]interface{}, error) { if err != nil { return nil, err } - - // https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/update + // Request body ref: https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/update req["mfa"] = multiFactorEnrollments{mfaInfo} } else { req[k] = v @@ -679,8 +697,24 @@ func validateAndFormatMfaSettings(mfaSettings MultiFactorSettings, methodType st 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) + if multiFactorInfo.Phone != nil { + // If PhoneMultiFactorInfo is provided, validate its PhoneNumber field + if err := validatePhone(multiFactorInfo.Phone.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.Phone.PhoneNumber) + } + // No need for the else here since we are returning from the function + } else if multiFactorInfo.PhoneNumber != "" { + // PhoneMultiFactorInfo is nil, check the deprecated PhoneNumber field + 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) + } + // The PhoneNumber field is deprecated, set it in PhoneMultiFactorInfo and inform about the deprecation. + multiFactorInfo.Phone = &PhoneMultiFactorInfo{ + PhoneNumber: multiFactorInfo.PhoneNumber, + } + } else { + // Both PhoneMultiFactorInfo and deprecated PhoneNumber are missing. + return nil, fmt.Errorf("\"PhoneMultiFactorInfo\" must be defined") } } obj, err := convertMultiFactorInfoToServerFormat(*multiFactorInfo) @@ -1079,17 +1113,28 @@ func (r *userQueryResponse) makeExportedUserRecord() (*ExportedUserRecord, error enrollmentTimestamp = t.Unix() * 1000 } - if factor.PhoneInfo == "" { + if factor.PhoneInfo != "" { + enrolledFactors = append(enrolledFactors, &MultiFactorInfo{ + UID: factor.MFAEnrollmentID, + DisplayName: factor.DisplayName, + EnrollmentTimestamp: enrollmentTimestamp, + FactorID: phoneMultiFactorID, + PhoneNumber: factor.PhoneInfo, + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: factor.PhoneInfo, + }, + }) + } else if factor.TOTPInfo != nil { + enrolledFactors = append(enrolledFactors, &MultiFactorInfo{ + UID: factor.MFAEnrollmentID, + DisplayName: factor.DisplayName, + EnrollmentTimestamp: enrollmentTimestamp, + FactorID: totpMultiFactorID, + TOTP: &TOTPMultiFactorInfo{}, + }) + } else { return nil, fmt.Errorf("unsupported multi-factor auth response: %#v", factor) } - - enrolledFactors = append(enrolledFactors, &MultiFactorInfo{ - UID: factor.MFAEnrollmentID, - DisplayName: factor.DisplayName, - EnrollmentTimestamp: enrollmentTimestamp, - FactorID: phoneMultiFactorID, - PhoneNumber: factor.PhoneInfo, - }) } return &ExportedUserRecord{ diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index 5143a2ff..ffa48655 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -69,11 +69,21 @@ var testUser = &UserRecord{ MultiFactor: &MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - UID: "0aaded3f-5e73-461d-aef9-37b48e3769be", + UID: "enrolledPhoneFactor", FactorID: "phone", EnrollmentTimestamp: 1614776780000, - PhoneNumber: "+1234567890", - DisplayName: "My MFA Phone", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+1234567890", + }, + PhoneNumber: "+1234567890", + DisplayName: "My MFA Phone", + }, + { + UID: "enrolledTOTPFactor", + FactorID: "totp", + EnrollmentTimestamp: 1614776780000, + TOTP: &TOTPMultiFactorInfo{}, + DisplayName: "My MFA TOTP", }, }, }, @@ -646,8 +656,10 @@ func TestInvalidCreateUser(t *testing.T) { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - UID: "EnrollmentID", - PhoneNumber: "+11234567890", + UID: "EnrollmentID", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, DisplayName: "Spouse's phone number", FactorID: "phone", }, @@ -658,7 +670,9 @@ func TestInvalidCreateUser(t *testing.T) { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - PhoneNumber: "invalid", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "invalid", + }, DisplayName: "Spouse's phone number", FactorID: "phone", }, @@ -669,7 +683,9 @@ func TestInvalidCreateUser(t *testing.T) { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - PhoneNumber: "+11234567890", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, DisplayName: "Spouse's phone number", FactorID: "phone", EnrollmentTimestamp: time.Now().UTC().Unix(), @@ -681,7 +697,9 @@ func TestInvalidCreateUser(t *testing.T) { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - PhoneNumber: "+11234567890", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, DisplayName: "Spouse's phone number", FactorID: "", }, @@ -692,8 +710,10 @@ func TestInvalidCreateUser(t *testing.T) { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - PhoneNumber: "+11234567890", - FactorID: "phone", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, + FactorID: "phone", }, }, }), @@ -772,9 +792,16 @@ var createUserCases = []struct { }, { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ + { + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, + DisplayName: "Phone Number active", + FactorID: "phone", + }, { PhoneNumber: "+11234567890", - DisplayName: "Spouse's phone number", + DisplayName: "Phone Number deprecated", FactorID: "phone", }, }, @@ -782,7 +809,11 @@ var createUserCases = []struct { map[string]interface{}{"mfaInfo": []*multiFactorInfoResponse{ { PhoneInfo: "+11234567890", - DisplayName: "Spouse's phone number", + DisplayName: "Phone Number active", + }, + { + PhoneInfo: "+11234567890", + DisplayName: "Phone Number deprecated", }, }, }, @@ -790,12 +821,16 @@ var createUserCases = []struct { (&UserToCreate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - PhoneNumber: "+11234567890", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, DisplayName: "number1", FactorID: "phone", }, { - PhoneNumber: "+11234567890", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, DisplayName: "number2", FactorID: "phone", }, @@ -875,9 +910,11 @@ func TestInvalidUpdateUser(t *testing.T) { (&UserToUpdate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - UID: "enrolledSecondFactor1", - PhoneNumber: "+11234567890", - FactorID: "phone", + UID: "enrolledSecondFactor1", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, + FactorID: "phone", }, }, }), @@ -886,8 +923,10 @@ func TestInvalidUpdateUser(t *testing.T) { (&UserToUpdate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - UID: "enrolledSecondFactor1", - PhoneNumber: "invalid", + UID: "enrolledSecondFactor1", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "invalid", + }, DisplayName: "Spouse's phone number", FactorID: "phone", }, @@ -1038,17 +1077,25 @@ var updateUserCases = []struct { (&UserToUpdate{}).MFASettings(MultiFactorSettings{ EnrolledFactors: []*MultiFactorInfo{ { - UID: "enrolledSecondFactor1", - PhoneNumber: "+11234567890", + UID: "enrolledSecondFactor1", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, DisplayName: "Spouse's phone number", FactorID: "phone", EnrollmentTimestamp: time.Now().Unix(), }, { - UID: "enrolledSecondFactor2", + UID: "enrolledSecondFactor2", + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, PhoneNumber: "+11234567890", DisplayName: "Spouse's phone number", FactorID: "phone", }, { + Phone: &PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, PhoneNumber: "+11234567890", DisplayName: "Spouse's phone number", FactorID: "phone", @@ -1883,10 +1930,16 @@ func TestMakeExportedUser(t *testing.T) { MFAInfo: []*multiFactorInfoResponse{ { PhoneInfo: "+1234567890", - MFAEnrollmentID: "0aaded3f-5e73-461d-aef9-37b48e3769be", + MFAEnrollmentID: "enrolledPhoneFactor", DisplayName: "My MFA Phone", EnrolledAt: "2021-03-03T13:06:20.542896Z", }, + { + TOTPInfo: &TOTPInfo{}, + MFAEnrollmentID: "enrolledTOTPFactor", + DisplayName: "My MFA TOTP", + EnrolledAt: "2021-03-03T13:06:20.542896Z", + }, }, } diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index c1301f6d..1c37cd0a 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -435,7 +435,14 @@ func TestCreateUserMFA(t *testing.T) { EnrolledFactors: []*auth.MultiFactorInfo{ { PhoneNumber: "+11234567890", - DisplayName: "Spouse's phone number", + DisplayName: "Phone Number deprecated", + FactorID: "phone", + }, + { + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+19876543210", + }, + DisplayName: "Phone Number active", FactorID: "phone", }, }, @@ -445,14 +452,27 @@ func TestCreateUserMFA(t *testing.T) { t.Fatalf("CreateUser() = %v; want = nil", err) } defer deleteUser(user.UID) - var factor []*auth.MultiFactorInfo = []*auth.MultiFactorInfo{ + var factors []*auth.MultiFactorInfo = []*auth.MultiFactorInfo{ { - UID: user.MultiFactor.EnrolledFactors[0].UID, - DisplayName: "Spouse's phone number", - FactorID: "phone", + UID: user.MultiFactor.EnrolledFactors[0].UID, + DisplayName: "Phone Number deprecated", + FactorID: "phone", + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, PhoneNumber: "+11234567890", EnrollmentTimestamp: user.MultiFactor.EnrolledFactors[0].EnrollmentTimestamp, }, + { + UID: user.MultiFactor.EnrolledFactors[1].UID, + DisplayName: "Phone Number active", + FactorID: "phone", + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+19876543210", + }, + PhoneNumber: "+19876543210", + EnrollmentTimestamp: user.MultiFactor.EnrolledFactors[1].EnrollmentTimestamp, + }, } want := auth.UserRecord{ EmailVerified: true, @@ -466,7 +486,7 @@ func TestCreateUserMFA(t *testing.T) { }, TokensValidAfterMillis: user.TokensValidAfterMillis, MultiFactor: &auth.MultiFactorSettings{ - EnrolledFactors: factor, + EnrolledFactors: factors, }, } if !reflect.DeepEqual(*user, want) { @@ -710,6 +730,113 @@ func TestUpdateUser(t *testing.T) { }) } +func TestUpdateUserMFA(t *testing.T) { + // Creates a new user for testing purposes. The user's uid will be + // '$name_$tenRandomChars' and email will be + // '$name_$tenRandomChars@example.com'. + createTestUserWithMFA := func(name string) *auth.UserRecord { + // TODO(rsgowman: This function could usefully be employed throughout + // this file. + tenRandomChars := generateRandomAlphaNumericString(10) + userRecord, err := client.CreateUser(context.Background(), + (&auth.UserToCreate{}). + Email(name+"_"+tenRandomChars+"@example.com"). + EmailVerified(true). + MFASettings(auth.MultiFactorSettings{ + EnrolledFactors: []*auth.MultiFactorInfo{ + { + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, + DisplayName: "Phone Number active", + FactorID: "phone", + }, + { + PhoneNumber: "+19876543210", + DisplayName: "Phone Number deprecated", + FactorID: "phone", + }, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + return userRecord + } + // Create a test user with MFA settings for testing + user := createTestUserWithMFA("UpdateUserMFA") + defer deleteUser(user.UID) + + // Define the updated MFA factors + updatedFactors := []*auth.MultiFactorInfo{ + { + DisplayName: "Phone Number active updated", + FactorID: "phone", + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, + }, + { + DisplayName: "Phone Number deprecated updated", + FactorID: "phone", + PhoneNumber: "+19876543210", + }, + } + + // Update the MFA settings + params := (&auth.UserToUpdate{}).MFASettings(auth.MultiFactorSettings{ + EnrolledFactors: updatedFactors, + }) + + updatedUser, err := client.UpdateUser(context.Background(), user.UID, params) + if err != nil { + t.Fatal(err) + } + + want := auth.UserRecord{ + EmailVerified: true, + UserInfo: &auth.UserInfo{ + Email: updatedUser.Email, + UID: updatedUser.UID, + ProviderID: "firebase", + }, + UserMetadata: &auth.UserMetadata{ + CreationTimestamp: updatedUser.UserMetadata.CreationTimestamp, + }, + TokensValidAfterMillis: updatedUser.TokensValidAfterMillis, + MultiFactor: &auth.MultiFactorSettings{ + EnrolledFactors: []*auth.MultiFactorInfo{ + { + UID: updatedUser.MultiFactor.EnrolledFactors[0].UID, + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+11234567890", + }, + PhoneNumber: "+11234567890", + DisplayName: "Phone Number active updated", + FactorID: "phone", + EnrollmentTimestamp: updatedUser.MultiFactor.EnrolledFactors[0].EnrollmentTimestamp, + }, + { + UID: updatedUser.MultiFactor.EnrolledFactors[1].UID, + Phone: &auth.PhoneMultiFactorInfo{ + PhoneNumber: "+19876543210", + }, + PhoneNumber: "+19876543210", + DisplayName: "Phone Number deprecated updated", + FactorID: "phone", + EnrollmentTimestamp: updatedUser.MultiFactor.EnrolledFactors[1].EnrollmentTimestamp, + }, + }, + }, + } + + // Compare the updated user with the expected user record + if !reflect.DeepEqual(*updatedUser, want) { + t.Errorf("UpdateUser() = %#v; want = %#v", *updatedUser, want) + } +} + func TestDisableUser(t *testing.T) { user := newUserWithParams(t) defer deleteUser(user.UID) diff --git a/testdata/get_user.json b/testdata/get_user.json index a2cac48b..0bf86f95 100644 --- a/testdata/get_user.json +++ b/testdata/get_user.json @@ -35,9 +35,15 @@ "mfaInfo": [ { "phoneInfo": "+1234567890", - "mfaEnrollmentId": "0aaded3f-5e73-461d-aef9-37b48e3769be", + "mfaEnrollmentId": "enrolledPhoneFactor", "displayName": "My MFA Phone", "enrolledAt": "2021-03-03T13:06:20.542896Z" + }, + { + "totpInfo": {}, + "mfaEnrollmentId": "enrolledTOTPFactor", + "displayName": "My MFA TOTP", + "enrolledAt": "2021-03-03T13:06:20.542896Z" } ] } diff --git a/testdata/list_users.json b/testdata/list_users.json index bf94ff49..2b630686 100644 --- a/testdata/list_users.json +++ b/testdata/list_users.json @@ -33,12 +33,18 @@ "customAttributes": "{\"admin\": true, \"package\": \"gold\"}", "tenantId": "testTenant", "mfaInfo": [ - { - "phoneInfo": "+1234567890", - "mfaEnrollmentId": "0aaded3f-5e73-461d-aef9-37b48e3769be", - "displayName": "My MFA Phone", - "enrolledAt": "2021-03-03T13:06:20.542896Z" - } + { + "phoneInfo": "+1234567890", + "mfaEnrollmentId": "enrolledPhoneFactor", + "displayName": "My MFA Phone", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + }, + { + "totpInfo": {}, + "mfaEnrollmentId": "enrolledTOTPFactor", + "displayName": "My MFA TOTP", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + } ] }, { @@ -73,12 +79,18 @@ "customAttributes": "{\"admin\": true, \"package\": \"gold\"}", "tenantId": "testTenant", "mfaInfo": [ - { - "phoneInfo": "+1234567890", - "mfaEnrollmentId": "0aaded3f-5e73-461d-aef9-37b48e3769be", - "displayName": "My MFA Phone", - "enrolledAt": "2021-03-03T13:06:20.542896Z" - } + { + "phoneInfo": "+1234567890", + "mfaEnrollmentId": "enrolledPhoneFactor", + "displayName": "My MFA Phone", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + }, + { + "totpInfo": {}, + "mfaEnrollmentId": "enrolledTOTPFactor", + "displayName": "My MFA TOTP", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + } ] }, {