diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs index 13b0a9b4..84ae9bba 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs @@ -236,7 +236,7 @@ public async Task GetUser() public async Task UpdateUser() { var original = await this.userBuilder.CreateUserAsync(new UserRecordArgs()); - var updateArgs = TemporaryUserBuilder.RandomUserRecordArgs(); + var updateArgs = TemporaryUserBuilder.RandomUserRecordArgsWithMfa(); updateArgs.Uid = original.Uid; updateArgs.EmailVerified = true; @@ -253,6 +253,12 @@ public async Task UpdateUser() Assert.Null(user.UserMetaData.LastSignInTimestamp); Assert.Equal(2, user.ProviderData.Length); Assert.Empty(user.CustomClaims); + Assert.Equal(updateArgs.Mfa[0].DisplayName, user.Mfa[0].DisplayName); + Assert.Equal(updateArgs.Mfa[0].PhoneInfo, user.Mfa[0].PhoneInfo); + Assert.Equal(updateArgs.Mfa[0].MfaFactorId, user.Mfa[0].MfaFactorId); + // Check that the enrolled at Timespan is within one second of the set time. + // The range, is because the google cloud API rounds the enrolledAt date by some milliseconds. + Assert.InRange(updateArgs.Mfa[0].EnrolledAt - user.Mfa[0].EnrolledAt, TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1)); } [Fact] @@ -658,6 +664,17 @@ public async Task SignInWithEmailLink() Assert.True(user.EmailVerified); } + [Fact] + public async Task CreateUserWithMfa() + { + var user = await this.userBuilder.CreateRandomUserMithMfaAsync(); + var userGetData = await this.Auth.GetUserAsync(user.Uid); + + Assert.NotNull(userGetData); + Assert.NotNull(userGetData.Mfa); + await this.Auth.DeleteUserAsync(user.Uid); + } + private async Task AssertValidIdTokenAsync( string idToken, bool checkRevoked = false) { diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs index 7baa861e..71013088 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Policy; using System.Threading; using System.Threading.Tasks; using FirebaseAdmin.Auth; @@ -56,11 +57,33 @@ public static UserRecordArgs RandomUserRecordArgs() }; } + public static UserRecordArgs RandomUserRecordArgsWithMfa() + { + var userArgs = RandomUserRecordArgs(); + userArgs.EmailVerified = true; + userArgs.Mfa = new List() + { + new MfaEnrollmentArgs + { + PhoneInfo = userArgs.PhoneNumber, + DisplayName = "test factor", + EnrolledAt = DateTime.UtcNow, + MfaFactorId = MfaFactorIdType.Phone, + }, + }; + return userArgs; + } + public async Task CreateRandomUserAsync() { return await this.CreateUserAsync(RandomUserRecordArgs()); } + public async Task CreateRandomUserMithMfaAsync() + { + return await this.CreateUserAsync(RandomUserRecordArgsWithMfa()); + } + public async Task CreateUserAsync(UserRecordArgs args) { // Make sure we never create more than 1000 users in a single instance. diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs index 1b316fd6..d1c0fb9e 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs @@ -130,6 +130,15 @@ internal static async Task CreateUserAsync() DisplayName = "John Doe", PhotoUrl = "http://www.example.com/12345678/photo.png", Disabled = false, + Mfa = new List + { + new MfaEnrollmentArgs + { + PhoneInfo = "+11234567890", + MfaFactorId = MfaFactorIdType.Phone, + DisplayName = "John Does personal phone", + }, + }, }; UserRecord userRecord = await FirebaseAuth.DefaultInstance.CreateUserAsync(args); // See the UserRecord reference doc for the contents of userRecord. @@ -458,6 +467,16 @@ internal static async Task UpdateUserAsync(string uid) DisplayName = "Jane Doe", PhotoUrl = "http://www.example.com/12345678/photo.png", Disabled = true, + Mfa = new List() + { + new MfaEnrollmentArgs() + { + PhoneInfo = "+11234567890", + MfaFactorId = MfaFactorIdType.Phone, + MfaEnrollmentId = "CoolId", + EnrolledAt = DateTime.UtcNow, + }, + }, }; UserRecord userRecord = await FirebaseAuth.DefaultInstance.UpdateUserAsync(args); // See the UserRecord reference doc for the contents of userRecord. diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs new file mode 100644 index 00000000..7f4d2edd --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs @@ -0,0 +1,78 @@ +using System; +using FirebaseAdmin.Auth; +using FirebaseAdmin.Auth.Users; +using Xunit; + +namespace FirebaseAdmin.Tests.Auth +{ + public class MfaEnrollmentTest + { + [Fact] + public void NullResponse() + { + Assert.Throws(() => new MfaEnrollment(null)); + } + + [Fact] + public void EmptyUid() + { + Assert.Throws(() => new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = string.Empty, + })); + } + + [Fact] + public void NoInfo() + { + Assert.Throws(() => new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + PhoneInfo = null, + TotpInfo = null, + })); + } + + [Fact] + public void ConflictingInfo() + { + Assert.Throws(() => new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + PhoneInfo = "+10987654321", + TotpInfo = new(), + })); + } + + [Fact] + public void ValidPhoneFactor() + { + var enrollment = new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + PhoneInfo = "+10987654321", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), + }); + + Assert.Equal("testId", enrollment.MfaEnrollmentId); + Assert.Equal("+10987654321", enrollment.PhoneInfo); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, enrollment.MfaFactorId); + } + + [Fact] + public void ValidTotpFactor() + { + var enrollment = new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + TotpInfo = new(), + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), + }); + + Assert.Equal("testId", enrollment.MfaEnrollmentId); + Assert.Equal(DateTime.Parse("2014 - 10 - 03T15:01:23Z"), enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Totp, enrollment.MfaFactorId); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs index 384972a8..129114eb 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs @@ -103,6 +103,30 @@ public void AllProperties() PhoneNumber = "+10987654321", }, }, + Mfa = new List() + { + new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "mfa1", + DisplayName = "SecondFactor", + PhoneInfo = "*********321", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), + }, + new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "mfa2", + DisplayName = "SecondSecondFactor", + PhoneInfo = "*********322", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), + }, + new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "totp", + DisplayName = "totp", + TotpInfo = new(), + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), + }, + }, }; var user = new UserRecord(response); @@ -141,6 +165,30 @@ public void AllProperties() Assert.Equal("+10987654321", provider.PhoneNumber); Assert.Equal("https://other.com/user.png", provider.PhotoUrl); + var mfaEnrollment = user.Mfa[0]; + Assert.Equal("mfa1", mfaEnrollment.MfaEnrollmentId); + Assert.Equal("SecondFactor", mfaEnrollment.DisplayName); + Assert.Equal("*********321", mfaEnrollment.PhoneInfo); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), mfaEnrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, mfaEnrollment.MfaFactorId); + Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); + + mfaEnrollment = user.Mfa[1]; + Assert.Equal("mfa2", mfaEnrollment.MfaEnrollmentId); + Assert.Equal("SecondSecondFactor", mfaEnrollment.DisplayName); + Assert.Equal("*********322", mfaEnrollment.PhoneInfo); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), mfaEnrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, mfaEnrollment.MfaFactorId); + Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); + + mfaEnrollment = user.Mfa[2]; + Assert.Equal("totp", mfaEnrollment.MfaEnrollmentId); + Assert.Equal("totp", mfaEnrollment.DisplayName); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), mfaEnrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Totp, mfaEnrollment.MfaFactorId); + Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); + Assert.Null(mfaEnrollment.PhoneInfo); + var metadata = user.UserMetaData; Assert.NotNull(metadata); Assert.Equal(UserRecord.UnixEpoch.AddMilliseconds(100), metadata.CreationTimestamp); diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index 616a8120..745865e4 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -76,6 +76,7 @@ public async Task GetUserById(TestConfig config) Assert.Empty(userRecord.ProviderData); Assert.Null(userRecord.UserMetaData.CreationTimestamp); Assert.Null(userRecord.UserMetaData.LastSignInTimestamp); + Assert.Null(userRecord.Mfa); config.AssertRequest("accounts:lookup", handler.Requests[0]); var request = NewtonsoftJsonSerializer.Instance @@ -113,6 +114,20 @@ public async Task GetUserByIdWithProperties(TestConfig config) ], ""createdAt"": 100, ""lastLoginAt"": 150, + ""mfaInfo"": [ + { + ""mfaEnrollmentId"": ""test"", + ""displayName"": ""test name"", + ""enrolledAt"": ""2014-10-02T15:01:23Z"", + ""phoneInfo"": ""+10987654321"" + }, + { + ""mfaEnrollmentId"": ""test2"", + ""displayName"": ""test name2"", + ""enrolledAt"": ""2014-10-03T15:01:23Z"", + ""totpInfo"": {} + }, + ], }"; var handler = new MockMessageHandler() { @@ -157,6 +172,19 @@ public async Task GetUserByIdWithProperties(TestConfig config) Assert.Equal("+10987654321", provider.PhoneNumber); Assert.Equal("https://other.com/user.png", provider.PhotoUrl); + var enrollment = userRecord.Mfa[0]; + Assert.Equal("test", enrollment.MfaEnrollmentId); + Assert.Equal("test name", enrollment.DisplayName); + Assert.Equal("+10987654321", enrollment.PhoneInfo); + Assert.Equal(DateTime.Parse("2014-10-02T15:01:23Z").ToUniversalTime(), enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, enrollment.MfaFactorId); + + enrollment = userRecord.Mfa[1]; + Assert.Equal("test2", enrollment.MfaEnrollmentId); + Assert.Equal("test name2", enrollment.DisplayName); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z").ToUniversalTime(), enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Totp, enrollment.MfaFactorId); + var metadata = userRecord.UserMetaData; Assert.NotNull(metadata); Assert.Equal(UserRecord.UnixEpoch.AddMilliseconds(100), metadata.CreationTimestamp); @@ -227,6 +255,7 @@ public async Task GetUserByEmail(TestConfig config) Assert.Empty(userRecord.ProviderData); Assert.Null(userRecord.UserMetaData.CreationTimestamp); Assert.Null(userRecord.UserMetaData.LastSignInTimestamp); + Assert.Null(userRecord.Mfa); config.AssertRequest("accounts:lookup", handler.Requests[0]); var request = NewtonsoftJsonSerializer.Instance @@ -296,6 +325,7 @@ public async Task GetUserByPhoneNumber(TestConfig config) Assert.Empty(userRecord.ProviderData); Assert.Null(userRecord.UserMetaData.CreationTimestamp); Assert.Null(userRecord.UserMetaData.LastSignInTimestamp); + Assert.Null(userRecord.Mfa); config.AssertRequest("accounts:lookup", handler.Requests[0]); var request = NewtonsoftJsonSerializer.Instance @@ -850,7 +880,7 @@ public async Task CreateUser(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Empty(request); } @@ -881,7 +911,7 @@ public async Task CreateUserWithArgs(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.True((bool)request["disabled"]); Assert.Equal("Test User", request["displayName"]); Assert.Equal("user@example.com", request["email"]); @@ -915,7 +945,7 @@ public async Task CreateUserWithExplicitDefaults(TestConfig config) Assert.Equal("user1", user.Uid); Assert.Equal(config.TenantId, user.TenantId); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); @@ -1125,7 +1155,7 @@ public async Task UpdateUser(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts:update", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Equal("user1", request["localId"]); Assert.True((bool)request["disableUser"]); Assert.Equal("Test User", request["displayName"]); @@ -1135,7 +1165,7 @@ public async Task UpdateUser(TestConfig config) Assert.Equal("+1234567890", request["phoneNumber"]); Assert.Equal("https://example.com/user.png", request["photoUrl"]); - var claims = NewtonsoftJsonSerializer.Instance.Deserialize((string)request["customAttributes"]); + var claims = NewtonsoftJsonSerializer.Instance.Deserialize((string)request["customAttributes"]); Assert.True((bool)claims["admin"]); Assert.Equal(4L, claims["level"]); Assert.Equal("gold", claims["package"]); diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs new file mode 100644 index 00000000..3a8f705f --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs @@ -0,0 +1,78 @@ +using System; +using FirebaseAdmin.Auth.Users; + +namespace FirebaseAdmin.Auth +{ + /// + /// Class to contain readonly user mfa information. + /// + public class MfaEnrollment + { + internal MfaEnrollment(GetAccountInfoResponse.MfaEnrollment enrollment) + { + if (enrollment == null) + { + throw new ArgumentNullException("enrollment cannot be null!"); + } + + if (enrollment.MfaEnrollmentId == string.Empty) + { + throw new ArgumentException("Enrollment cannot be an empty string!"); + } + + this.MfaEnrollmentId = enrollment.MfaEnrollmentId; + this.DisplayName = enrollment.DisplayName; + this.EnrolledAt = enrollment.EnrolledAt; + this.PhoneInfo = enrollment.PhoneInfo; + this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; + + if (enrollment.PhoneInfo != null && enrollment.TotpInfo != null) + { + throw new ArgumentException("Cannot have multiple conflicting info fields!"); + } + + if (enrollment.PhoneInfo != null) + { + this.MfaFactorId = MfaFactorIdType.Phone; + } + else if (enrollment.TotpInfo != null) + { + this.MfaFactorId = MfaFactorIdType.Totp; + } + else + { + throw new ArgumentException("Must have atleast one valid multifactor factor type!"); + } + } + + /// + /// Gets the Mfa enrollments factor id. + /// + public MfaFactorIdType MfaFactorId { get; } + + /// + /// Gets the Mfa enrollments ID. + /// + public string MfaEnrollmentId { get; } + + /// + /// Gets the Mfa enrollments display name. + /// + public string DisplayName { get; } + + /// + /// Gets the when the user enrolled this second factor. + /// + public DateTime EnrolledAt { get; } + + /// + /// Gets the phone info of the mfa enrollment. + /// + public string PhoneInfo { get; } + + /// + /// Gets unobfuscated phone info of the mfa enrollment. + /// + public string UnobfuscatedPhoneInfo { get; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs new file mode 100644 index 00000000..c1ca0c80 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs @@ -0,0 +1,198 @@ +using System; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Auth +{ + /// + /// A specification for enrolling a user for mfa or updating their enrollment. + /// + public sealed class MfaEnrollmentArgs + { + private Optional displayName; + private Optional phoneInfo; + + /// + /// Gets or sets the Mfa enrollments factor id. + /// + public MfaFactorIdType MfaFactorId { get; set; } + + /// + /// Gets or sets the Mfa enrollments ID. + /// + public string MfaEnrollmentId { get; set; } + + /// + /// Gets or sets the Mfa enrollments display name. + /// + public string DisplayName + { + get => this.displayName?.Value; + set => this.displayName = this.Wrap(value); + } + + /// + /// Gets or sets the when the user enrolled this second factor. + /// + public DateTime EnrolledAt { get; set; } + + /// + /// Gets or sets the phone info of the mfa enrollment. + /// + public string PhoneInfo + { + get => this.phoneInfo?.Value; + set => this.phoneInfo = this.Wrap(value); + } + + /// + /// Gets or sets unobfuscated phone info of the mfa enrollment. + /// + public string UnobfuscatedPhoneInfo { get; set; } + + internal static string CheckEmail(string email, bool required = false) + { + if (email == null) + { + if (required) + { + throw new ArgumentNullException(nameof(email)); + } + } + else if (email == string.Empty) + { + throw new ArgumentException("Email must not be empty"); + } + else if (!Regex.IsMatch(email, @"^[^@]+@[^@]+$")) + { + throw new ArgumentException($"Invalid email address: {email}"); + } + + return email; + } + + internal static string CheckPhoneNumber(string phoneNumber, bool required = false) + { + if (phoneNumber == null) + { + if (required) + { + throw new ArgumentNullException(nameof(phoneNumber)); + } + } + else if (phoneNumber == string.Empty) + { + throw new ArgumentException("Phone number must not be empty."); + } + else if (!phoneNumber.StartsWith("+")) + { + throw new ArgumentException( + "Phone number must be a valid, E.164 compliant identifier starting with a '+' sign."); + } + + return phoneNumber; + } + + internal CreateUserRequest ToCreateUserRequest() + { + return new CreateUserRequest(this); + } + + internal UpdateUserRequest ToUpdateUserRequest() + { + return new UpdateUserRequest(this); + } + + private Optional Wrap(T value) + { + return new Optional(value); + } + + internal sealed class CreateUserRequest + { + internal CreateUserRequest(MfaEnrollmentArgs enrollment) + { + if (enrollment.MfaFactorId != MfaFactorIdType.Phone) + { + throw new ArgumentException("Unsupported factor type: " + enrollment.MfaFactorId.ToString()); + } + + if (enrollment.PhoneInfo == null) + { + throw new ArgumentException("Cannot enroll user to invalid phone number!"); + } + + this.DisplayName = enrollment.DisplayName; + + this.PhoneInfo = enrollment.PhoneInfo; + } + + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + } + + internal sealed class UpdateUserRequest + { + internal UpdateUserRequest(MfaEnrollmentArgs enrollment) + { + // Similiar to the node implementation, the mfa factor sanity checks are tied to the MfaFactorId. + // I decided to do an enumerator, since the factorTypes have a strictly defined set and are + // not used in the API. I decided not to do inheritance, since the only true factor type, that can be + // edited here, is the phone factor type. + if (enrollment.MfaFactorId == MfaFactorIdType.Phone) + { + this.PhoneInfo = CheckPhoneNumber(enrollment.PhoneInfo, true); + } + + if (enrollment.MfaFactorId == MfaFactorIdType.Totp) + { + this.TotpInfo = new TotpInfoJson(); + } + + this.DisplayName = enrollment.DisplayName; + this.EnrolledAt = enrollment.EnrolledAt; + this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; + } + + [JsonProperty("mfaEnrollmentId")] + public string MfaEnrollmentId { get; set; } + + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + [JsonProperty("enrolledAt")] + public DateTime EnrolledAt { get; set; } + + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + + [JsonProperty("unobfuscatedPhoneInfo")] + public string UnobfuscatedPhoneInfo { get; set; } + + [JsonProperty("totpInfo")] + public TotpInfoJson TotpInfo { get; set; } + + // internal class to get an empty JSON and for the future, if something can be given here to the API. + internal class TotpInfoJson + { + } + } + + /// + /// Wraps a nullable value. Used to differentiate between parameters that have not been set, and + /// the parameters that have been explicitly set to null. + /// + private class Optional + { + internal Optional(T value) + { + this.Value = value; + } + + internal T Value { get; private set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs new file mode 100644 index 00000000..07f0af6a --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs @@ -0,0 +1,22 @@ +using Google.Apis.Util; + +namespace FirebaseAdmin.Auth +{ + /// + /// Enumerator to encapsulate the different possible MfaFactorIds. + /// + public enum MfaFactorIdType + { + /// + /// Value for the phone factor id. + /// + [StringValue("phone")] + Phone = 1, + + /// + /// Value for the phone factor id. + /// + [StringValue("totp")] + Totp = 2, + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs index 4c69b669..cf35a92f 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs @@ -71,6 +71,15 @@ internal UserRecord(GetAccountInfoResponse.User user) } } + if (user.Mfa != null && user.Mfa.Count > 0) + { + this.Mfa = new List(); + foreach (var enrollment in user.Mfa) + { + this.Mfa.Add(new MfaEnrollment(enrollment)); + } + } + this.validSinceTimestampInSeconds = user.ValidSince; // newtonsoft's json deserializer will convert an iso8601 format @@ -133,6 +142,11 @@ internal UserRecord(GetAccountInfoResponse.User user) /// public IUserInfo[] ProviderData { get; } + /// + /// Gets a list of Mfa data for this user. Possibly empty or null. + /// + public List Mfa { get; } + /// /// Gets a timestamp that indicates the earliest point in time at which a valid ID token /// could have been issued to this user. Tokens issued prior to this timestamp are diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index 433deb1b..aa1d3c32 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -31,6 +31,7 @@ public sealed class UserRecordArgs private Optional photoUrl; private Optional phoneNumber; private Optional> customClaims; + private Optional> mfa; private bool? disabled = null; private bool? emailVerified = null; @@ -89,6 +90,15 @@ public bool Disabled set => this.disabled = value; } + /// + /// Gets or sets the Mfa info of the user. + /// + public IList Mfa + { + get => this.mfa?.Value; + set => this.mfa = this.Wrap(value); + } + /// /// Gets or sets the password of the user. /// @@ -280,6 +290,22 @@ internal CreateUserRequest(UserRecordArgs args) this.PhoneNumber = CheckPhoneNumber(args.PhoneNumber); this.PhotoUrl = CheckPhotoUrl(args.PhotoUrl); this.Uid = CheckUid(args.Uid); + + if (args.mfa != null) + { + if (args.mfa.Value != null) + { + this.MfaInfo = new List(); + foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) + { + this.MfaInfo.Add(mfaEnrollment.ToCreateUserRequest()); + } + } + else + { + this.MfaInfo = null; + } + } } [JsonProperty("disabled")] @@ -305,6 +331,9 @@ internal CreateUserRequest(UserRecordArgs args) [JsonProperty("localId")] public string Uid { get; set; } + + [JsonProperty("mfaInfo")] + public IList MfaInfo { get; set; } } internal sealed class UpdateUserRequest @@ -322,6 +351,24 @@ internal UpdateUserRequest(UserRecordArgs args) this.EmailVerified = args.emailVerified; this.Password = CheckPassword(args.Password); this.ValidSince = args.ValidSince; + if (args.mfa != null) + { + if (args.mfa.Value != null) + { + this.Mfa = new() + { + Enrollments = new List(), + }; + foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) + { + this.Mfa.Enrollments.Add(mfaEnrollment.ToUpdateUserRequest()); + } + } + else + { + this.Mfa = null; + } + } if (args.displayName != null) { @@ -399,6 +446,9 @@ internal UpdateUserRequest(UserRecordArgs args) [JsonProperty("validSince")] public long? ValidSince { get; set; } + [JsonProperty("mfa")] + public MfaInfo Mfa { get; set; } + private void AddDeleteAttribute(string attribute) { if (this.DeleteAttribute == null) @@ -418,6 +468,12 @@ private void AddDeleteProvider(string provider) this.DeleteProvider.Add(provider); } + + internal sealed class MfaInfo + { + [JsonProperty("enrollments")] + public IList Enrollments { get; set; } + } } /// diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs index e535c5c5..4b536e0b 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs @@ -276,6 +276,7 @@ internal async Task UpdateUserAsync( var payload = args.ToUpdateUserRequest(); var response = await this.PostAndDeserializeAsync( "accounts:update", payload, cancellationToken).ConfigureAwait(false); + if (payload.Uid != (string)response.Result["localId"]) { throw UnexpectedResponseException( @@ -408,7 +409,9 @@ private async Task GetUserAsync( { var response = await this.PostAndDeserializeAsync( "accounts:lookup", query.Build(), cancellationToken).ConfigureAwait(false); + var result = response.Result; + if (result == null || result.Users == null || result.Users.Count == 0) { throw new FirebaseAuthException( @@ -430,6 +433,7 @@ private async Task> PostAndDeserializeAsync(request, cancellationToken) diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs index 76eb4542..7b9f94f2 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs @@ -135,6 +135,12 @@ internal sealed class User /// [JsonProperty(PropertyName = "tenantId")] public string TenantId { get; set; } + + /// + /// Gets or sets the user's Mfa enrollments. + /// + [JsonProperty(PropertyName = "mfaInfo")] + public List Mfa { get; set; } } /// @@ -178,5 +184,69 @@ internal sealed class Provider [JsonProperty(PropertyName = "providerId")] public string ProviderID { get; set; } } + + /// + /// Json data binding, for mfa info. + /// + internal sealed class MfaEnrollment + { + /// + /// Gets or sets the Mfa enrollment id. + /// + [JsonProperty("mfaEnrollmentId")] + public string MfaEnrollmentId { get; set; } + + /// + /// Gets or sets the Mfa display name. + /// + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + /// + /// Gets or sets when the enrollment was made. + /// + [JsonProperty("enrolledAt")] + public DateTime EnrolledAt { get; set; } + + /// + /// Gets or sets the totpInfo object of the mfa enrollment. + /// + [JsonProperty("totpInfo")] + public TotpInfoResponse TotpInfo { get; set; } + + /// + /// Gets or sets the phone info of the mfa enrollment. + /// + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + + /// + /// Gets or sets the email info, of the enrollment. + /// + [JsonProperty("emailInfo")] + public EmailInfoResponse EmailInfo { get; set; } + + /// + /// Gets or sets the unobfuscated phone info, of the enrollment. + /// + [JsonProperty("unobfuscatedPhoneInfo")] + public string UnobfuscatedPhoneInfo { get; set; } + + /// + /// Json data binding, for the email info response field. + /// + internal sealed class EmailInfoResponse + { + [JsonProperty("emailAddress")] + public string EmailAddress { get; set; } + } + + /// + /// Json data binding, for the totp info response field. + /// + internal sealed class TotpInfoResponse + { + } + } } }