[Required]
, and [EmailAddress]
are validation attributes that check the value of the InputModel.Email
property is correct.public partial class IndexModel : PageModel { [BindProperty] public InputModel Input { get; set; } public class InputModel { [Required] [EmailAddress] public string Email { get; set; } } }
[Phone]
attribute for validating phone numbers. Unfortunately, this attribute is simplistic and doesn't take into account the highly complex set of rules and exceptions that apply to phone numbers.dotnet-aspnet-codegenerator
you can run from the command line. I'll show how to use the global tool to scaffold the /Identity/Account/Manage
page that we need to customize.dotnet ef database update
aspnet-codegenerator
global tool. Note that there is a bug in the latest version of the tool at the time of writing (2.2.0), so the following installs the last known good version.dotnet tool install -g dotnet-aspnet-codegenerator --version 2.1.6
aspnet-codegenerator
global tool so, install it in to into your project using the NuGet Package Manager, Package Manager Console CLI, or by editing the the ValidatePhoneNumberDemo.csproj file. Be sure to set the PrivateAssets
attribute to All
, so that the package is used only during development.<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.2.1" PrivateAssets="All" />
Account.Manage.Index
Razor Page. As well as listing the files to generate, you need to specify the full name of the EF Core DB Context for your project.dotnet aspnet-codegenerator identity --files Account.Manage.Index -dc ValidatePhoneNumberDemo.Data.ApplicationDbContext
dotnet aspnet-codegenerator identity --listFiles
.0
is apparently a valid phone number!<ItemGroup>
section of the project file should look like this (version numbers may be higher):<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.App"/> <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" /> <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.2.1" PrivateAssets="All" /> <PackageReference Include="Twilio" Version="5.22.0" /> </ItemGroup>
"TwilioAccountDetails": { "AccountSID": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "AuthToken": "your_auth_token" }
using Twilio;
at the top of Startup.cs, and add the following at the end of ConfigureServices
:public void ConfigureServices(IServiceCollection services) { // existing configuration var accountSid = Configuration["TwilioAccountDetails:AccountSID"]; var authToken = Configuration["TwilioAccountDetails:AuthToken"]; TwilioClient.Init(accountSid, authToken); }
HttpClientFactory
features introduced in ASP.NET Core 2.1, see my previous post on the Twilio blog for an alternative approach....
”) in a code block represents a section redacted for brevity.[ { "Text": "United States of America", "Value": "US" }, { "Text": "United Kingdom of Great Britain and Northern Ireland", "Value": "GB" }, { "Text": "Canada", "Value": "CA" }, ... ]
List<SelectListItem>
. Create the CountryService.cs file in the root of your project and add the following code:using System; using System.Collections.Generic; using System.IO; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Rendering; using Newtonsoft.Json; namespace ValidatePhoneNumberDemo { public class CountryService { private readonly IHostingEnvironment _environment; private readonly Lazy<List<SelectListItem>> _countries; public CountryService(IHostingEnvironment environment) { _environment = environment; _countries = new Lazy<List<SelectListItem>>(LoadCountries); } public List<SelectListItem> GetCountries() { return _countries.Value; } private List<SelectListItem> LoadCountries() { var fileInfo = _environment.ContentRootFileProvider.GetFileInfo("countries.json"); using (var stream = fileInfo.CreateReadStream()) using (var streamReader = new StreamReader(stream)) using (var jsonTextReader = new JsonTextReader(streamReader)) { var serializer = new JsonSerializer(); return serializer.Deserialize<List<SelectListItem>>(jsonTextReader); } } } }
Lazy<>
to provide Lazy Initialization, an optimization that only loads the countries from the JSON file once, when they’re needed, in a thread-safe manner. It also uses the streaming support of Newtonsoft.Json to deserialize the contents directly to a List<SelectListItem>
, instead of loading it as a string
first. The deserialized list of countries is exposed via the GetCountries()
method.CountryService
you must register it with the app's dependency injection (DI) container. Open up Startup.cs and add the following line at the end of the ConfigureServices
method:services.AddSingleton<CountryService>();
using Microsoft.AspNetCore.Mvc.Rendering;
at the top of the file, and inject an instance of CountryService
into the constructor. Create a new property AvailableCountries
on your page model and use the injected CountryService
to assign the list of countries. Note that if you're using Visual Studio, you need to expand the Index.cshtml file node in Solution Explorer to see the code-behind file, Index.cshtml.cs.namespace ValidatePhoneNumberDemo.Areas.Identity.Pages.Account.Manage { public partial class IndexModel : PageModel { public IndexModel( UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, IEmailSender emailSender, CountryService countryService) { _userManager = userManager; _signInManager = signInManager; _emailSender = emailSender; // Load the countries from the service AvailableCountries = countryService.GetCountries(); } public List<SelectListItem> AvailableCountries { get; } ... } }
InputModel
and add the PhoneNumberCountryCode
property:namespace ValidatePhoneNumberDemo.Areas.Identity.Pages.Account.Manage { public partial class IndexModel : PageModel { ... public class InputModel { [Required] [EmailAddress] public string Email { get; set; } [Phone] [Display(Name = "Phone number")] public string PhoneNumber { get; set; } // The country selected by the user. [Display(Name = "Phone number country")] public string PhoneNumberCountryCode { get; set; } } ... } }
... <div class="form-group"> <label asp-for="Input.PhoneNumberCountryCode"></label> <select asp-for="Input.PhoneNumberCountryCode" asp-items="Model.AvailableCountries" class="form-control"></select> <span asp-validation-for="Input.PhoneNumberCountryCode" class="text-danger"></span> </div> ...
<select>
element populated with the values in the AvailableCountries
property, which binds to the Input.PhoneNumberCountryCode
property on form POST. If you run your app now and navigate to the account management page, you should see the country dropdown displayed above the phone number option.<select>
element. Consequently, the first item is selected and sent back to the user. That's OK for our purposes, but a more comprehensive approach might be to select the default country based on the user's location.CountryService
loading our list of country codes, and our dropdown for the user to select their country. Now we'll add the validation call to the Twilio API.using
statements:using Twilio.Exceptions; using Twilio.Rest.Lookups.V1;
OnPostAsync
:... var phoneNumber = await _userManager.GetPhoneNumberAsync(user); if (Input.PhoneNumber != phoneNumber) { var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber); if (!setPhoneResult.Succeeded) { var userId = await _userManager.GetUserIdAsync(user); throw new InvalidOperationException($"Unexpected error occurred setting phone number for user with ID '{userId}'."); } } ...
... var phoneNumber = await _userManager.GetPhoneNumberAsync(user); if (Input.PhoneNumber != phoneNumber) { try { var numberDetails = await PhoneNumberResource.FetchAsync( pathPhoneNumber: new Twilio.Types.PhoneNumber(Input.PhoneNumber), countryCode: Input.PhoneNumberCountryCode, type: new List<string> { "carrier" }); // only allow user to set phone number if capable of receiving SMS if (numberDetails?.Carrier != null && numberDetails.Carrier.TryGetValue("type", out var phoneNumberType) && phoneNumberType == "landline") { ModelState.AddModelError($"{nameof(Input)}.{nameof(Input.PhoneNumber)}", $"The number you entered does not appear to be capable of receiving SMS ({phoneNumberType}). Please enter a different value and try again"); return Page(); } var numberToSave = numberDetails.PhoneNumber.ToString(); var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, numberToSave); if (!setPhoneResult.Succeeded) { var userId = await _userManager.GetUserIdAsync(user); throw new InvalidOperationException($"Unexpected error occurred setting phone number for user with ID '{userId}'."); } } catch (ApiException ex) { ModelState.AddModelError($"{nameof(Input)}.{nameof(Input.PhoneNumber)}", $"The number you entered was not valid (Twilio code {ex.Code}), please check it and try again"); return Page(); } } ...
var phoneNumber = await _userManager.GetPhoneNumberAsync(user); if (Input.PhoneNumber != phoneNumber) { // Phone number has changed, so validate and save it }
PhoneNumberResource
helper to make an asynchronous call to the Twilio API, passing in the PhoneNumber
and PhoneNumberCountryCode
from the InputModel
. We wrap the call in a try
-catch
block, as it will throw an exception if it's invalid. In that case we display a generic error and redisplay the form. You could also log the exception, but be wary of storing personally identifiable information (PII) in log messages.try { var numberDetails = await PhoneNumberResource.FetchAsync( pathPhoneNumber: new Twilio.Types.PhoneNumber(Input.PhoneNumber), countryCode: Input.PhoneNumberCountryCode, type: new List<string> { "carrier" }); // validation successful } catch (ApiException ex) { ModelState.AddModelError($"{nameof(Input)}.{nameof(Input.PhoneNumber)}", $"The number you entered was not valid (Twilio code {ex.Code}), please check it and try again"); return Page(); }
AddModelError()
. That means the error will be shown next to the phone number field as well as in the Validation Summary Tag Helper by default. I've also included the Twilio code in the error message for demonstration purposes. In practice you probably won't want to expose that to users, but you may well want to use it for logging and metric purposes. "carrier"
to the type
parameter of FetchAsync()
. This is not necessary for the validation itself, but it can provide some useful additional information for some business use-cases. Including the "carrier"
also returns the likely phone number type of the number.string
values: landline
, mobile
, or voip
. We use that value to try and determine if the number can receive SMS. If it can't, we redisplay the form and make the user enter a different value.// only allow user to set phone number if capable of receiving SMS if (numberDetails?.Carrier != null && numberDetails.Carrier.TryGetValue("type", out var phoneNumberType) && phoneNumberType == "landline") { ModelState.AddModelError($"{nameof(Input)}.{nameof(Input.PhoneNumber)}", $"The number you entered does not appear to be capable of receiving SMS ({phoneNumberType}). Please enter a different value and try again"); return Page(); }
var numberToSave = numberDetails.PhoneNumber.ToString(); var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, numberToSave); if (!setPhoneResult.Succeeded) { var userId = await _userManager.GetUserIdAsync(user); throw new InvalidOperationException($"Unexpected error occurred setting phone number for user with ID '{userId}'."); }
+1
format) as shown on the right.dotnet-aspnet-codegenerator
global tool, and how to load countries for a dropdown list from a JSON file. It's important to include the country when validating phone numbers as different countries have different rules.