Skip to main content

Detecting the user's time zone at registration

This article walks through the process of capturing and detecting a user's time zone at the point of registration. This information can then be used to display time-related information in the local zone of the user. Note that, in most cases, you should still store datetime information in UTC; the time zone is only used for local display.

This setup assumes a site running Razor Pages in ASP.NET Core 2.2, although there's nothing particularly framework-specific in what follows. The same ideas should carry over quite easily to, for example, a regular ASP.NET MVC site.

We start with a basic registration page:

Register.cshtml

Register.cshtml.cs

Register

The first step is just to add a <select> input with the time zones that the user can select. Since this application is running on Windows servers in Azure, we are going to store the id of the time zone as defined by TimeZoneInfo.GetSystemTimeZones. We populate a list of time zones and present this on the page:

Register.cshtml.cs

Register.cshtml

Register

This works, but it is not a great experience for the user. We can pick a default time zone, perhaps where we expect most of our users to reside (Central Standard Time in the example above), but that guess is going to be wrong for everyone else. For example, I happen to live in Eastern Standard Time. Ideally, we'd like to automatically detect and preselect the appropriate time zone.

The standard way to collect this information would be to use the resolvedOptions() method on the Intl.DateTimeFormat object. There are two challenges with this.

The first challenge is that browser support is still a little spotty. We can get around that by using the Moment Timezone library. This library provides a convenient guess() method that uses the Intl/DateTimeFormat object if it is available, but then falls back to some clever date offset trickery when that fails.

The second challenge is that the client-side detection will return a time zone based on the identifiers in the IANA Time Zone Database, not the Windows identifier. We could handle this one of two ways. First, of course, we could simply change our backend logic to use the IANA time zone ids. I decided not to go that route because I'd rather deal with the mapping at the point of registration and not during normal execution of the web site. But that's a call you will have to make based on what makes the most sense for your application. There are rumors that .Net Core / .Net Framework might move to using the IANA ids at some point, which would make this step obsolete. In any case, the code below does the mapping for now.

First, the server-side code is going to build a map from IANA time zone ids to Windows time zone ids. We can use the TimeZoneConverter library to assist us with this mapping. Run the following from the Package Manager Console:

Install-Package TimeZoneConverter

Add the server-side code to create the map:

namespace WebApplication2.Pages
{
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.RazorPages;
    using Microsoft.AspNetCore.Mvc.Rendering;
    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.Diagnostics;
    using System.Linq;
    using TimeZoneConverter;

    public class RegisterModel : PageModel
    {
        private readonly Lazy<SelectListItem[]> timezones = new Lazy<SelectListItem[]>(() => TimeZoneInfo.GetSystemTimeZones().Select(tz => new SelectListItem(tz.DisplayName, tz.Id, tz.Id == "Central Standard Time")).ToArray());

        [Required]
        [EmailAddress]
        [BindProperty]
        public string Email { get; set; }

        [Required]
        [Display(Name = "First name")]
        [BindProperty]
        public string FirstName { get; set; }

        [Required]
        [Display(Name = "Last name")]
        [BindProperty]
        public string LastName { get; set; }

        [Required]
        [Display(Name = "Time zone")]
        [BindProperty]
        public string TimeZone { get; set; }

        public Dictionary<string, string> TimeZoneMap { get; private set; }

        public SelectListItem[] Timezones
        {
            get { return this.timezones.Value; }
        }

        public void OnGet()
        {
            this.CreateTimeZoneMap();
        }

        public void OnPost()
        {
            if (ModelState.IsValid)
            {
                Debug.WriteLine($"Registration: FirstName = {FirstName}, LastName = {LastName}, Email = {Email}, TimeZone = {TimeZone}");
            }
        }

        private void CreateTimeZoneMap()
        {
            this.TimeZoneMap = new Dictionary<string, string>();
            foreach (string ianaName in TZConvert.KnownIanaTimeZoneNames)
            {
                if (TZConvert.TryIanaToWindows(ianaName, out string windowsId))
                {
                    this.TimeZoneMap.Add(ianaName, windowsId);
                }
            }
        }
    }
}

And then the client-side code to select the detected time zone when the form is loaded:

@page
@model RegisterModel
<h1>Create an account</h1>
<form method="post">
    <div asp-validation-summary="ModelOnly"></div>
    <div class="form-group">
        <label asp-for="FirstName"></label>
        <input asp-for="FirstName" class="form-control" />
        <span asp-validation-for="FirstName" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="LastName"></label>
        <input asp-for="LastName" class="form-control" />
        <span asp-validation-for="LastName" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Email"></label>
        <input asp-for="Email" class="form-control" />
        <span asp-validation-for="Email" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="TimeZone"></label>
        <select asp-for="TimeZone" asp-items="Model.Timezones" class="form-control"></select>
        <span asp-validation-for="TimeZone" class="text-danger"></span>
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
</form>
@section Scripts {
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js" integrity="sha256-4iQZ6BVL4qNKlQ27TExEhBN1HFPvAvAMbFavKKosSWQ=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data.min.js" integrity="sha256-15jnh2lee6Li94j6XCbw8PRzNZe29O/W9i97yXVyRmA=" crossorigin="anonymous"></script>
    <script type="text/javascript">
      var map = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.TimeZoneMap));
      var ianaTz = moment.tz.guess();
      if (ianaTz) {
        var inputTz = map[ianaTz];
        if (inputTz) {
          document.querySelector("#TimeZone").value = inputTz;
        }
      }
    </script>
}

The client-side code emits a (large) object that maps all the IANA time zones to the appropriate Windows time zone. The object will look something like this:

(There is obviously some optimization that can be done with this, both server-side and client-side, but that's outside the scope of this article.)

The moment.js library detects the user's time zone, and if that succeeds, it looks for a matching key in the map object. If one if found, then the matching select option is selected. The end result is that the user's detected time zone is preselected when the form is loaded:

Register

At this point, everything is working but there is one small thing to fix. When a user submits an invalid form (missing email, for example), we are still emitting the time zone detection code. However, at that point the user has already selected a time zone, so we don't want to "re-guess", possibly overwriting the user's previous selection. The code above kind of works by accident: we are only creating the map on a GET request, so the client-side map will always be null and fail to match. However, we can optimize this by only emitting the code for the client-side map when there is no value for our page model's TimeZone property:

@page
@model RegisterModel
<h1>Create an account</h1>
<form method="post">
    <div asp-validation-summary="ModelOnly"></div>
    <div class="form-group">
        <label asp-for="FirstName"></label>
        <input asp-for="FirstName" class="form-control" />
        <span asp-validation-for="FirstName" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="LastName"></label>
        <input asp-for="LastName" class="form-control" />
        <span asp-validation-for="LastName" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Email"></label>
        <input asp-for="Email" class="form-control" />
        <span asp-validation-for="Email" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="TimeZone"></label>
        <select asp-for="TimeZone" asp-items="Model.Timezones" class="form-control"></select>
        <span asp-validation-for="TimeZone" class="text-danger"></span>
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
</form>
@section Scripts {
    @if (Model.TimeZoneMap != null)
    {
      <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js" integrity="sha256-4iQZ6BVL4qNKlQ27TExEhBN1HFPvAvAMbFavKKosSWQ=" crossorigin="anonymous"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data.min.js" integrity="sha256-15jnh2lee6Li94j6XCbw8PRzNZe29O/W9i97yXVyRmA=" crossorigin="anonymous"></script>
      <script type="text/javascript">
      var map = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.TimeZoneMap));
      var ianaTz = moment.tz.guess();
      if (ianaTz) {
        var inputTz = map[ianaTz];
        if (inputTz) {
          document.querySelector("#TimeZone").value = inputTz;
        }
      }
        </script>
    }
}

Comments

Popular posts from this blog

Integrating Google reCAPTCHA v2 with an ASP.NET Core Razor Pages form

CAPTCHA (completely automated public Turing test to tell computers and humans apart) reduces the likelihood of automated bots successfully submitting forms on your web site. Google's reCAPTCHA implementation is familiar to users (as these things go), and is a free service. Integrating it within an ASP.NET Core Razor Pages form is straightforward.Obtain Google API keyThe first thing we need to do is to sign up for an API key pair. The latest documentation for this process can be found on the reCAPTCHA Developer's Guide. Google will provide us with a site key and a secret key. I am storing these keys in the Visual Studio Secret Manager so they are not accidentally checked in with version control. To access the Secret Manager, right-click on the project and select "Manage User Secrets...":No, these are not my real keys...As part of the sign up process for the reCAPTCHA keys, we specify the domains for which the keys are valid. Be sure to add "localhost" if you…

Mitigating the risk of brute force login compromise using Redis cache in ASP.NET Core Identity

Any application that requires user authentication must take adequate steps to protect the user accounts for which it is responsible. This includes correctly handling workflows such as proper password hashing and storage, providing feedback that doesn't disclose information useful to an attacker, providing means for password reset, etc. The ASP.NET Core Identity membership system provides much of this functionality out-of-the-box, using tried and tested implementations that avoid common mistakes and pitfalls. It is an excellent platform on which to build when developing your application's authentication system.ASP.NET Core Identity provides a means of mitigating brute force login attempts through user lockout. After a configurable number of failed login attempts, a user's account is locked for a period of time. Both the maximum number of attempts, and the lockout period, are configurable.While this is certainly a valid strategy, it does have some weaknesses:The system can b…