My web application is Microsoft.NET.Sdk.Web project, the target framework is net5.0. I'm using Redis to cache the AuthenticationTicket. The implementation is as below.
public class RedisCacheTicketStore : ITicketStore
{
private readonly RedisCacheService cache;
private readonly IConfiguration configuration;
private readonly ILogger logger;
public RedisCacheTicketStore(
RedisCacheService redisCacheService,
IConfiguration configuration,
ILogger logger)
{
this.cache = redisCacheService;
this.configuration = configuration;
this.logger = logger;
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = $"AuthSessionStore-{Guid.NewGuid()}";
await RenewAsync(key, ticket);
logger.Debug("The ticket {Key} was stored.", key);
return key;
}
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var timeToLive = TimeSpan.FromMinutes(5); // For testing purpose
//var timeToLive = TimeSpan.FromMinutes(configuration.GetValue("SessionCookieLifetimeMinutes", 60));
var bytes = SerializeToBytes(ticket);
cache.Set(key, bytes, timeToLive);
logger.Debug("The ticket was renew and will be expire at {ExpiresAtUtc}", ticket.Properties.ExpiresUtc);
return Task.CompletedTask;
}
public Task<AuthenticationTicket> RetrieveAsync(string key)
{
var bytes = cache.Get<byte[]>(key);
var ticket = DeserializeFromBytes(bytes);
logger.Debug("The ticket {Key} was retrieved.", key);
return Task.FromResult(ticket);
}
public Task RemoveAsync(string key)
{
cache.Remove(key);
logger.Debug("The ticket {Key} was removed.", key);
return Task.CompletedTask;
}
private static byte[] SerializeToBytes(AuthenticationTicket source)
{
return TicketSerializer.Default.Serialize(source);
}
private static AuthenticationTicket DeserializeFromBytes(byte[] source)
{
return source == null ? null : TicketSerializer.Default.Deserialize(source);
}
}
I register RedisCacheTicketStore as the custom implementation of ITicketStore in the Startup class as below.
public partial class Startup
{
private void AddAuthentication(IServiceCollection services)
{
var sessionCookieLifetime = Configuration.GetValue("SessionCookieLifetimeMinutes", 60);
services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromMinutes(2);
options.SlidingExpiration = false; // For testing purpose
})
.AddOpenIdConnect(options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = ApplicationSettings.AdalSettings.Authority;
options.ClientId = ApplicationSettings.AdalSettings.ClientId;
options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
options.SaveTokens = true;
options.ClientSecret = ApplicationSettings.AdalSettings.AppKey;
options.Resource = ApplicationSettings.AdalSettings.ULTrackerResourceId;
options.Events = new OpenIdConnectEvents
{
OnAuthenticationFailed = OnAuthenticationFailed,
OnTokenValidated = OnSecurityTokenValidated
};
});
services
.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<ITicketStore>((options, store) => options.SessionStore = store);
services.AddSingleton(provider =>
new RedisCacheService(
ConnectionMultiplexer.Connect(Configuration["RedisConfigurationString"])));
services.AddSingleton<ITicketStore, RedisCacheTicketStore>();
}
private Task OnAuthenticationFailed(AuthenticationFailedContext context)
{
Serilog.Log.Logger.Error(context.Exception, "Authentication failed");
context.HandleResponse();
context.Response.Redirect("/Error/AuthenticationFailed");
return Task.CompletedTask;
}
private async Task OnSecurityTokenValidated(TokenValidatedContext context)
{
try
{
var claimsIdentity = context.Principal.Identity as ClaimsIdentity;
var roleClaims = context.Principal.Claims.SingleOrDefault(x => x.Type == Shared.Security.ClaimTypes.Role);
if (roleClaims != null)
{
claimsIdentity.RemoveClaim(roleClaims);
}
var roles = await GetUserRolesAndPermissionsAsync(claimsIdentity.Name);
claimsIdentity.AddRolesAndPermissions(roles);
}
catch (Exception exception) when (LogGetUserPermissionsException(exception))
{
throw;
}
async Task<Role[]> GetUserRolesAndPermissionsAsync(string userName)
{
var apiClient = new WebApiClient(
ApplicationSettings.WebApiUrlAddressSettings.ULTrackerApiBaseAddress,
new WebApiClientSettings());
var response = await apiClient.GetAsyncWithFormattableUri($"users/permissions?username={userName}", needsAuthorize: false);
response.EnsureSuccessStatusCode();
var roles = await apiClient.ReadJsonContentAsync<Role[]>(response);
return roles;
}
bool LogGetUserPermissionsException(Exception exception)
{
Serilog.Log.Logger.Error(exception, "An error has occurred while getting user permissions");
return false;
}
}
}
I try to set the Authentication Ticket Expire Time in 2 minutes to see what happens when the Cookie Ticket expires. I can log in and access the protected pages as expected.
The AuthenticationTicket is stored in Redis as below
When looking at the logs in the console, everything looks good so far.
But after 2 minutes, the Authentication Ticket expires, I cannot browse any URLs of application. It seems that the browser cannot send any requests to the application.
When looking at the logs in the console, nothing happens. I try to refresh the URL "https://localhost:44327/Policy/Search", the browser says that "This site can’t be reached. localhost took too long to respond.".
There is the same issue when I browse the URL "https://localhost:44327". I even try to use Postman or Firefox or Edge to send request but nothing happens in the console logs.
Does anyone know how to resolve this issue? How to handle cookie session in the distributed cache? My expectation is if the cookie expired, the asp.net core framework should return the login page. Thank you very much.
I found the solution to this issue.
When debugging I see that although I set ExpireTime of AuthenticationTicket to 2 minutes, the Expire Time of Cookie is null.
When a cookie is created in the browser, the value of Expries/Max-Age is "Session". I don't understand what this means.
I guess when the Authentication Ticket was expired, the browser sends request to the application but the asp.net core middleware somehow ignores the cookie and then doesn't respond to the browser.
I try to set Cookie Expiration explicitly as below and then it works as expected.
.AddCookie(options =>
{
options.Cookie.MaxAge = TimeSpan.FromMinutes(2);
options.ExpireTimeSpan = TimeSpan.FromMinutes(2);
options.SlidingExpiration = false; // For testing purpose
})
When looking at the Cookie of browser, I see that the Expires/Max-Age now has an explicit date time. After the Authentication Ticket was expired, I refresh the page, and see that the cookie is renewed with the new Expires/Max-Age.
When looking at the Network of browser, I see that the application does send request to Identity server to validate the token and renew the cookie.
But I still don't understand what is the difference between Cookie "Session" and Cookie has explicit Expires/Max-Age. Could someone help to explain?
I'm not sure if this issue is the bug of asp.net core or the designed behavior.
Thank you very much.