I won't lie to you all. I'm about at my wits end with this problem. I've killed about 4 hours of my time trying every solution in the book to fix a problem that I know has been very common for programmers attempting to introduce localization into their web applications. Whenever I try to change the culture of my web-page from English (en-US) to Korean (ko-KR), it defaults right back to English, which is set as the default. I've narrowed the problem down and I know that I am not generating a proper response cookie but none of the solutions I have found online for this apparently VERY common problem have helped me.
I have tried refreshing the cookies and the cache, I've added the Microsoft.AspNetCore.Localization;
and Microsoft.Extensions.Localization;
extensions, I have tried using the isEssential
parameter for the CookieOptions
object, I know my file structure is correct and that all my .resx files are where they should be because I'm able to see all of the translations that I should when I manually switch the website to Korean using ?culture=ko-KR, I believe I've configured my startup.cs, controller file, and partial view correctly, and I need a lifeline.
I followed an online tutorial to set up a dummy tutorial web application a few days prior and am able to successfully change the culture on that web application. Because of the way cookies work, I'm only able to change the language on my main application by changing the language on the dummy web application and that's not feasible at all.
Here's the important parts of my code.
startup.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Serialization;
using Serilog;
using Snape.DataLayer.Entities;
using Snape.Web.ScheduledProcessor;
using Snape.Web.Services;
using Snape.WebSecurity.Hashing;
using Snape.WebSecurity.Helpers;
using Snape.WebSecurity.Tokens;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Threading.Tasks;
using System.Linq;
namespace Snape.Web
{
public class Startup
{
private readonly IConfiguration _configProvider;
private readonly SigningConfiguration _signConfig;
private readonly IConfigurationRoot _constantsConfigProvider;
public Startup(IConfiguration configuration)
{
_configProvider = configuration;
_signConfig = new SigningConfiguration();
// Loading Constants.json && Configuration.json
var configurationBuilder = new ConfigurationBuilder()
.AddJsonFile($"{_configProvider.GetSection("Constants").Value}", optional: false, reloadOnChange: true)
.AddJsonFile($"{_configProvider.GetSection("Version").Value}", optional: true, reloadOnChange: true);
_constantsConfigProvider = configurationBuilder.Build();
// Initializing Serilog
Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(configuration).CreateLogger();
}
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(config =>
{
var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
services.AddDistributedMemoryCache(); // Adds a default in-memory implementation of IDistributedCache
services.AddSession(options => options.IdleTimeout = TimeSpan.FromHours(1));
/* Note this is commented out.
var cookieOptions = new Microsoft.AspNetCore.Http.CookieOptions()
{
Path = "/",
HttpOnly = false,
IsEssential = true, //<- there
Expires = DateTime.Now.AddMonths(1),
}; */
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => false;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
// we need to add localization to the project for views, controllers, and data annotations.
services.AddMvc()
// localization options are going to have their resources (language dictionary) stored in Resources folder.
.AddViewLocalization(opts => { opts.ResourcesPath = "Resources"; })
.AddViewLocalization(Microsoft.AspNetCore.Mvc.Razor.LanguageViewLocationExpanderFormat.Suffix)
.AddDataAnnotationsLocalization()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
// we are configuring the localization service to support a list of provided cultures.
services.Configure<RequestLocalizationOptions>(opts =>
{
// the list of supported cultures.
var supportedCultures = new List<CultureInfo>
{
new CultureInfo("en"),
new CultureInfo("en-US"),
new CultureInfo("ko"),
new CultureInfo("ko-KR"),
};
// set the localization default culture as english
opts.DefaultRequestCulture = new RequestCulture("en-US");
// supported cultures are the supportedCultures variable we defined above.
// formatiting dates, numbers, etc.
opts.SupportedCultures = supportedCultures;
// UI strings that we have localized
opts.SupportedUICultures = supportedCultures;
});
services.AddMvc().AddJsonOptions(options => options.SerializerSettings.ContractResolver = new DefaultContractResolver());
services.AddDbContext<SnapeDbContext>(options => options.UseLazyLoadingProxies().UseSqlite(_configProvider.GetConnectionString("SnapeDbConnection")));
services.AddSingleton(_constantsConfigProvider); // IConfigurationRoot
// *If* you need access to generic IConfiguration this is **required**
services.AddSingleton(_configProvider);
// Background task for data push
services.AddSingleton<IHostedService, DataPushingTask>();
// Background task for device's state check
services.AddSingleton<IHostedService, HeartbeatTask>();
// Background task for project's sync with cloud
services.AddSingleton<IHostedService, SyncingTask>();
// Background task for Purging
services.AddSingleton<IHostedService, PurgingTask>();
// Service for Internet Management
services.AddTransient<InternetService>();
services.Configure<TokenOptions>(_configProvider.GetSection("TokenOptions"));
var tokenOptions = _configProvider.GetSection("TokenOptions").Get<TokenOptions>();
services.AddSingleton<IPassportHasher, PasswordHasher>();
services.AddSingleton<ITokenHelper, TokenHelper>();
services.AddSingleton(_signConfig);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwtBearerOptions =>
{
jwtBearerOptions.RequireHttpsMetadata = false;
jwtBearerOptions.SaveToken = true;
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters()
{
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = tokenOptions.Issuer,
ValidAudience = tokenOptions.Audience,
IssuerSigningKey = _signConfig.Key,
ClockSkew = TimeSpan.Zero
};
});
services.Configure<FormOptions>(options =>
{
options.ValueCountLimit = int.MaxValue;
options.ValueLengthLimit = 1024 * 1024 * 100; // 100MB max len form data
});
System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("en-AU");
System.Threading.Thread.CurrentThread.CurrentUICulture = new System.Globalization.CultureInfo("en-AU");
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHostingEnvironment env, ILoggerFactory loggerFactory)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
// Enabling Logger
loggerFactory.AddSerilog();
app.UseHttpsRedirection();
app.UseStaticFiles();
// specify that globalization is being used in the pipeline.
var options = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
app.UseRequestLocalization(options.Value);
app.UseCookiePolicy();
app.UseSession();
//Add JWToken to all incoming HTTP Request Header
app.Use(async (context, next) => {
var jwToken = context.Session.GetString("JWToken");
if (!string.IsNullOrEmpty(jwToken))
{
context.Request.Headers.Add("Authorization", "Bearer " + jwToken);
}
await next();
});
app.UseAuthentication();
app.UseStatusCodePages(context => {
var response = context.HttpContext.Response;
if (response.StatusCode == (int)HttpStatusCode.Unauthorized || response.StatusCode == (int)HttpStatusCode.Forbidden)
{
response.Redirect("/Account/Login");
if (Utilities.WebUtility.IsAjaxRequest(context.HttpContext.Request))
response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
return Task.CompletedTask;
});
app.UseMvc(routes => {
routes.MapRoute(
name: "default",
template: "{controller=Account}/{action=Login}/{id?}");
routes.MapRoute(
"invalid_route",
"{*url}",
new { controller = "NotFound", action = "Index" });
});
#if RELEASE
if (_constantsConfigProvider.GetValue<bool>("CELLULAR_ON"))
{
Task.Run(async () => { await app.ApplicationServices.GetRequiredService<InternetService>().Enable(); });
}
#endif
}
}
}
AccountController.cs
public class AccountController : BaseController
{
// the localizer dictionary to translate languages for this controller.
readonly IStringLocalizer<AccountController> _localizer;
readonly IConfiguration _configProvider;
readonly IPersonFacade _personFacade;
readonly SnapeDbContext _dbContext;
readonly ITokenHelper _tokenHelper;
// AccountController constructor
public AccountController(IStringLocalizer<AccountController> localizer, SnapeDbContext dbContext, IConfiguration configuration, IPassportHasher passwordHasher, ITokenHelper tokenHandler,
IConfigurationRoot constantsConfig) : base(constantsConfig)
{
// initialize the localizer.
_localizer = localizer;
_dbContext = dbContext;
_tokenHelper = tokenHandler;
_configProvider = configuration;
_personFacade = new PersonFacade(dbContext);
}
[HttpPost] // annotation that specifies that this action is called on an HTTPPost
// this method needs to persist on both this page and any subsequent ones. Sets cookie for changed culture.
public IActionResult SetLanguage(string culture, string returnURL)
{
// set the cookie on the local machine of the Http Response to keep track of the language in question.
// append the cookie and its language options.
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName, // name of the cookie
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)), // create a string representation of the culture for storage
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddDays(1),
IsEssential = true, //<- there
} // expiration after one day.
);
return LocalRedirect(returnURL); // redirect to the original URL, the account page.
}
_SelectLanguagePartial.cshtml
@using Microsoft.AspNetCore.Builder
@using Microsoft.AspNetCore.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.Options
@inject IViewLocalizer Localizer
@inject IOptions<RequestLocalizationOptions> LocOptions
@{
// this code finds out what cultures I am supporting.
// it is all defined in startup.cs
var requestCulture = Context.Features.Get<IRequestCultureFeature>();
var cultureItems = LocOptions.Value.SupportedUICultures // all the supported cultures.
.Select(c => new SelectListItem { Value = c.Name, Text = c.DisplayName })
.ToList();
}
<!-- Partial view in ASP.NET MVC is special view which renders a portion of view content. It is just like a user control of a web form application.
Partial views can be reusable in multiple views. It helps us to reduce code duplication. In other words a partial view enables us to render a view within the parent view.
This partial view will be placed inside the layout.cshtml file, which is a shared (this is key) view that is under the wing of the home controller, just like the Home Views are -->
<!-- This code displays the culture/language dropdown.-->
<!-- Title of the dropdown-->
<div title="@Localizer["Request culture provider:"] @requestCulture?.Provider?.GetType().Name">
<!-- another post method-->
<!-- this form will call the setLanguage method under the AccountController.cs file. Even though this is a shared view, it's shared nature means the AccountController can still see it and act off of it.-->
<form id="selectLanguage" asp-controller="Account" asp-action="SetLanguage" asp-route-returnUrl="@Context.Request.Path"
method="post" class="form-horizontal" role="form">
<!-- Select dropdown for the language selection -->
<!-- asp-for indicates -->
<a style="color:white"> @Localizer["Language"]</a>
<select name="culture" asp-for="@requestCulture.RequestCulture.UICulture.Name" asp-items="cultureItems"></select>
<button type="submit" class="btn btn-default btn-xs">Save</button>
<!-- clicking on the save button will call the action setLanguage in the AccountController.-->
</form>
</div>
partialAsync call
<!-- import the partial view for selecting languages _SelectLanguagePartial.cshtml -->
@await Html.PartialAsync("_SelectLanguagePartial");
If anyone can shed some light on where to go from here, I would really appreciate it. I don't want to pull my hair out any longer over this. My last effort was setting isEssential = true
in the CookieOptions
to override the following request configuration defined in startup.cs
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => false;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
IMPORTANT EDIT: I don't know how or why, but I managed to get the localization working on a separate page on my web application, which is accessed after a user successfully logs in with a username and password. The selected language persists if the page changes which is what I'm looking for, even if I log out back to the login page. This is good but I still cannot change the language or culture from the login page of my web application, the back-end functionality of which is handled by AccountController.cs. Anyone have any ideas as to what could be causing this odd phenomenon?