Adding Localization to the ASP.NET Core Identity Pages

The article shows how to localize the new Identity Pages in an ASP.NET Core application. The views, code from the pages, and models require localized strings and are read from global identity resource files. This makes it easy to add translations for further languages, and prevents duplication.

Code: https://github.com/damienbod/AspNetCorePagesWebpack

The application is setup using this blog: Updating ASP.NET Core Identity to use Bootstrap 4

Setting up the localization in the application

To localize the identity views, code, and models, shared identity resource files are used for all the ASP.NET Core Identity pages. This makes it easy to localize this and re-use the resource files, instead of adding a 120 different resources files, for example when translating to 4 languages and duplicating the translations.

The ASP.NET Core application in this example is setup to localization to en-US and de-CH using cookies for the localized requests. The localization configuration is added to the default IoC and also the shared localized service which is used for all the Identity localizations.

The DataAnnotations is configured to use the IdentityResource class which represents the shared Identity resources.

public void ConfigureServices(IServiceCollection services)
{
    ...
	
	/**** Localization configuration ****/
	services.AddSingleton<IdentityLocalizationService>();
	services.AddSingleton<SharedLocalizationService>();
	services.AddLocalization(options => options.ResourcesPath = "Resources");

	services.Configure<RequestLocalizationOptions>(
		options =>
		{
			var supportedCultures = new List<CultureInfo>
				{
					new CultureInfo("en-US"),
					new CultureInfo("de-CH")
				};

			options.DefaultRequestCulture = new RequestCulture(culture: "de-CH", uiCulture: "de-CH");
			options.SupportedCultures = supportedCultures;
			options.SupportedUICultures = supportedCultures;
			options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider());
		});

	services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
		.AddViewLocalization()
		.AddDataAnnotationsLocalization(options =>
		{
			options.DataAnnotationLocalizerProvider = (type, factory) =>
			{
				var assemblyName = new AssemblyName(typeof(IdentityResource).GetTypeInfo().Assembly.FullName);
				return factory.Create("IdentityResource", assemblyName.Name);
			};
		});
}

The localization services which were configured are then used in the Configure method in the Startup class.

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
 ...

 var locOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
 app.UseRequestLocalization(locOptions.Value);

A dummy IdentityResource class is created for the identity localization resources.

namespace AspNetCorePagesIdentity.Resources
{
    /// <summary>
    /// Dummy class to group shared resources
    /// </summary>
    public class IdentityResource
    {
    }
}

The IdentityLocalizationService is the service which can be used in the Page views to localize the texts.

using Microsoft.Extensions.Localization;
using System.Reflection;

namespace AspNetCorePagesIdentity.Resources
{
    public class IdentityLocalizationService
    {
        private readonly IStringLocalizer _localizer;

        public IdentityLocalizationService(IStringLocalizerFactory factory)
        {
            var type = typeof(IdentityResource);
            var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName);
            _localizer = factory.Create("IdentityResource", assemblyName.Name);
        }

        public LocalizedString GetLocalizedHtmlString(string key)
        {
            return _localizer[key];
        }

        public LocalizedString GetLocalizedHtmlString(string key, string parameter)
        {
            return _localizer[key, parameter];
        }
    }
}

The language switch was implemented using this blog from Andrew Lock (Thanks):

Adding Localisation to an ASP.NET Core application

The HTML was then re-styled to use bootstrap 4.

Identity Page View Localization

The ASP.NET Core Page texts are localized then using the IdentityLocalizationService. This is injected into the view and then used to get the correct texts. The input labels are also localized using this, and not the display attribute from the model.

@page
@model LoginModel
@{
    ViewData["Title"] = @IdentityLocalizer.GetLocalizedHtmlString("ACCOUNT_LOGIN");
}
@inject IdentityLocalizationService IdentityLocalizer

<h2>@ViewData["Title"]</h2>
<div class="row">
    <div class="col-md-4">
        <section>
            <form method="post">
                <h4>@IdentityLocalizer.GetLocalizedHtmlString("ACCOUNT_USE_LOCAL_ACCOUNT_TO_LOG_IN")</h4>
                <hr />
                <div asp-validation-summary="All" class="text-danger"></div>
                <div class="form-group">
                    <label asp-for="Input.Email">@IdentityLocalizer.GetLocalizedHtmlString("EMAIL")</label>
                    <input asp-for="Input.Email" class="form-control" />
                    <span asp-validation-for="Input.Email" class="text-danger"></span>
                </div>
                <div class="form-group">
                    <label asp-for="Input.Password">@IdentityLocalizer.GetLocalizedHtmlString("PASSWORD")</label>
                    <input asp-for="Input.Password" class="form-control" />
                    <span asp-validation-for="Input.Password" class="text-danger"></span>
                </div>
                <div class="form-group">
                    <div class="checkbox">
                        <label asp-for="Input.RememberMe">
                            <input asp-for="Input.RememberMe" />
                            @IdentityLocalizer.GetLocalizedHtmlString("REMEMBER_ME")
                        </label>
                    </div>
                </div>
                <div class="form-group">
                    <button type="submit" class="btn btn-primary">@IdentityLocalizer.GetLocalizedHtmlString("ACCOUNT_LOGIN")</button>
                </div>
                <div class="form-group">
                    <p>
                        <a asp-page="./ForgotPassword">@IdentityLocalizer.GetLocalizedHtmlString("FORGOT_YOUR_PASSWORD")</a>
                    </p>
                    <p>
                        <a asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">@IdentityLocalizer.GetLocalizedHtmlString("REGISTER_AS_NEW_USER")</a>
                    </p>
                </div>
            </form>
        </section>
    </div>
    <div class="col-md-6 col-md-offset-2">
        <section>
            <h4>@IdentityLocalizer.GetLocalizedHtmlString("ACCOUNT_USE_ANOTHER_SERVICE_LOG_IN")</h4>
            <hr />
            @{
                if ((Model.ExternalLogins?.Count ?? 0) == 0)
                {
                    <div>
                        <p>
                            @IdentityLocalizer.GetLocalizedHtmlString("ACCOUNT_NO_EXTERNAL_LOGINS")
                        </p>
                    </div>
                }
                else
                {
                    <form asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
                        <div>
                            <p>
                                @foreach (var provider in Model.ExternalLogins)
                                {
                                    <button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
                                }
                            </p>
                        </div>
                    </form>
                }
            }
        </section>
    </div>
</div>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

Identity Page Code Localization

The Page code uses the IStringLocalizerFactory to localize the status messages, response messages and model errors. This is setup to use the shared IdentityResource resource which is used for all the Identity translations. Then the messages, texts are translated as required. The model items use the ErrorMessage property to defined the resource identifier.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Localization;
using AspNetCorePagesIdentity.Resources;
using System.Reflection;

namespace AspNetCorePagesIdentity.Areas.Identity.Pages.Account
{
    [AllowAnonymous]
    public class LoginModel : PageModel
    {
        private readonly SignInManager<IdentityUser> _signInManager;
        private readonly ILogger<LoginModel> _logger;
        private readonly IStringLocalizer _identityLocalizer;

        public LoginModel(SignInManager<IdentityUser> signInManager, ILogger<LoginModel> logger, IStringLocalizerFactory factory)
        {
            _signInManager = signInManager;
            _logger = logger;

             var type = typeof(IdentityResource);
             var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName);
            _identityLocalizer = factory.Create("IdentityResource", assemblyName.Name);

            }

        [BindProperty]
        public InputModel Input { get; set; }

        public IList<AuthenticationScheme> ExternalLogins { get; set; }

        public string ReturnUrl { get; set; }

        [TempData]
        public string ErrorMessage { get; set; }

        public class InputModel
        {
            [Required(ErrorMessage = "EMAIL_REQUIRED")]
            [EmailAddress(ErrorMessage = "EMAIL_INVALID")]
            public string Email { get; set; }

            [Required(ErrorMessage = "PASSWORD_REQUIRED")]
            [DataType(DataType.Password)]
            public string Password { get; set; }

            public bool RememberMe { get; set; }
        }

        public async Task OnGetAsync(string returnUrl = null)
        {
            if (!string.IsNullOrEmpty(ErrorMessage))
            {
                ModelState.AddModelError(string.Empty, ErrorMessage);
            }

            returnUrl = returnUrl ?? Url.Content("~/");

            // Clear the existing external cookie to ensure a clean login process
            await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

            ReturnUrl = returnUrl;
        }

        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");

            if (ModelState.IsValid)
            {
                // This doesn't count login failures towards account lockout
                // To enable password failures to trigger account lockout, set lockoutOnFailure: true
                var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
                if (result.Succeeded)
                {
                    _logger.LogInformation("User logged in.");
                    return LocalRedirect(returnUrl);
                }
                if (result.RequiresTwoFactor)
                {
                    return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
                }
                if (result.IsLockedOut)
                {
                    _logger.LogWarning("User account locked out.");
                    return RedirectToPage("./Lockout");
                }
                else
                {
                    ModelState.AddModelError(string.Empty, _identityLocalizer["INVALID_LOGIN_ATTEMPT"]);
                    return Page();
                }
            }

            // If we got this far, something failed, redisplay form
            return Page();
        }
    }
}

When the app is runs, everything is localized. Here’s an example in German:

Links:

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-2.1#querystringrequestcultureprovider

https://github.com/aspnet/Identity

https://andrewlock.net/adding-localisation-to-an-asp-net-core-application/

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-2.1&tabs=visual-studio%2Caspnetcore2x

Advertisements

5 comments

  1. Hi Damien,

    I think you can make this simpler by immediately injecting an IStringLocalizer?
    In that case you don’t have to use the factory to create the localizer (and jump through hoops for obtaining the assembly name), and you also don’t need the IdentityLocalizationService, as you can inject that same IStringLocalizer straight into the pageview (or into the _ViewImports.cshtml for that matter).

    1. Hi fretje

      thanks. I tried this first, but the type matching did not work, searched for the resources in the wrong folder

      Greetings Damien

      1. I think this is due to the fact that your IdentityResource class is not in the root namespace (in your case, that’s “AspNetCorePagesIdentity”). I don’t know exactly why, but I read somewhere that that has to be the case for everything to work properly.

        For reference, the relevant part of my app startup looks like this (where my “SharedResources” is equivalent to your “IdentityResource”):

        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Latest)
        .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
        .AddDataAnnotationsLocalization(options =>
        {
        options.DataAnnotationLocalizerProvider = (type, factory) => factory.Create(typeof(SharedResources));
        })

  2. In my previous comment, “IStringLocalizer” should be “IStringLocalizer{lower than}IdentityResource{greater than}”, but apparently tags are being stripped out…

  3. thx for sharing! what would be your take on strongly-typed localization possibilities (even in data annotation cases) using alternative provider? you can read more here: https://blog.tech-fellow.net/2018/01/27/dblocalizationprovider-in-asp-net-core/

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

w

Connecting to %s

%d bloggers like this: