// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Authentication; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Authentication.Cookies { internal class CookieAuthenticationHandler : AuthenticationHandler { private const string HeaderValueNoCache = "no-cache"; private const string HeaderValueMinusOne = "-1"; private const string SessionIdClaim = "Microsoft.AspNetCore.Authentication.Cookies-SessionId"; private bool _shouldRefresh; private DateTimeOffset? _refreshIssuedUtc; private DateTimeOffset? _refreshExpiresUtc; private string _sessionKey; private Task _readCookieTask; private Task EnsureCookieTicket() { // We only need to read the ticket once if (_readCookieTask == null) { _readCookieTask = ReadCookieTicket(); } return _readCookieTask; } private void CheckForRefresh(AuthenticationTicket ticket) { var currentUtc = Options.SystemClock.UtcNow; var issuedUtc = ticket.Properties.IssuedUtc; var expiresUtc = ticket.Properties.ExpiresUtc; var allowRefresh = ticket.Properties.AllowRefresh ?? true; if (issuedUtc != null && expiresUtc != null && Options.SlidingExpiration && allowRefresh) { var timeElapsed = currentUtc.Subtract(issuedUtc.Value); var timeRemaining = expiresUtc.Value.Subtract(currentUtc); if (timeRemaining < timeElapsed) { RequestRefresh(ticket); } } } private void RequestRefresh(AuthenticationTicket ticket) { var issuedUtc = ticket.Properties.IssuedUtc; var expiresUtc = ticket.Properties.ExpiresUtc; if (issuedUtc != null && expiresUtc != null) { _shouldRefresh = true; var currentUtc = Options.SystemClock.UtcNow; _refreshIssuedUtc = currentUtc; var timeSpan = expiresUtc.Value.Subtract(issuedUtc.Value); _refreshExpiresUtc = currentUtc.Add(timeSpan); } } private async Task ReadCookieTicket() { var cookie = Options.CookieManager.GetRequestCookie(Context, Options.CookieName); if (string.IsNullOrEmpty(cookie)) { return AuthenticateResult.Skip(); } var ticket = Options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding()); if (ticket == null) { return AuthenticateResult.Fail("Unprotect ticket failed"); } if (Options.SessionStore != null) { var claim = ticket.Principal.Claims.FirstOrDefault(c => c.Type.Equals(SessionIdClaim)); if (claim == null) { return AuthenticateResult.Fail("SessionId missing"); } _sessionKey = claim.Value; ticket = await Options.SessionStore.RetrieveAsync(_sessionKey); if (ticket == null) { return AuthenticateResult.Fail("Identity missing in session store"); } } var currentUtc = Options.SystemClock.UtcNow; var issuedUtc = ticket.Properties.IssuedUtc; var expiresUtc = ticket.Properties.ExpiresUtc; if (expiresUtc != null && expiresUtc.Value < currentUtc) { if (Options.SessionStore != null) { await Options.SessionStore.RemoveAsync(_sessionKey); } return AuthenticateResult.Fail("Ticket expired"); } CheckForRefresh(ticket); // Finally we have a valid ticket return AuthenticateResult.Success(ticket); } protected override async Task HandleAuthenticateAsync() { var result = await EnsureCookieTicket(); if (!result.Succeeded) { return result; } var context = new CookieValidatePrincipalContext(Context, result.Ticket, Options); await Options.Events.ValidatePrincipal(context); if (context.Principal == null) { return AuthenticateResult.Fail("No principal."); } if (context.ShouldRenew) { RequestRefresh(result.Ticket); } return AuthenticateResult.Success(new AuthenticationTicket(context.Principal, context.Properties, Options.AuthenticationScheme)); } private CookieOptions BuildCookieOptions() { var cookieOptions = new CookieOptions { Domain = Options.CookieDomain, HttpOnly = Options.CookieHttpOnly, Path = Options.CookiePath ?? (OriginalPathBase.HasValue ? OriginalPathBase.ToString() : "/"), }; if (Options.CookieSecure == CookieSecurePolicy.SameAsRequest) { cookieOptions.Secure = Request.IsHttps; } else { cookieOptions.Secure = Options.CookieSecure == CookieSecurePolicy.Always; } return cookieOptions; } protected override async Task FinishResponseAsync() { // Only renew if requested, and neither sign in or sign out was called if (!_shouldRefresh || SignInAccepted || SignOutAccepted) { return; } var ticket = (await HandleAuthenticateOnceSafeAsync())?.Ticket; if (ticket != null) { var properties = ticket.Properties; if (_refreshIssuedUtc.HasValue) { properties.IssuedUtc = _refreshIssuedUtc; } if (_refreshExpiresUtc.HasValue) { properties.ExpiresUtc = _refreshExpiresUtc; } if (Options.SessionStore != null && _sessionKey != null) { await Options.SessionStore.RenewAsync(_sessionKey, ticket); var principal = new ClaimsPrincipal( new ClaimsIdentity( new[] { new Claim(SessionIdClaim, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) }, Options.AuthenticationScheme)); ticket = new AuthenticationTicket(principal, null, Options.AuthenticationScheme); } var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding()); var cookieOptions = BuildCookieOptions(); if (properties.IsPersistent && _refreshExpiresUtc.HasValue) { cookieOptions.Expires = _refreshExpiresUtc.Value.ToUniversalTime(); } Options.CookieManager.AppendResponseCookie( Context, Options.CookieName, cookieValue, cookieOptions); await ApplyHeaders(shouldRedirectToReturnUrl: false, properties: properties); } } protected override async Task HandleSignInAsync(SignInContext signin) { // Process the request cookie to initialize members like _sessionKey. var result = await EnsureCookieTicket(); var cookieOptions = BuildCookieOptions(); var signInContext = new CookieSigningInContext( Context, Options, Options.AuthenticationScheme, signin.Principal, new AuthenticationProperties(signin.Properties), cookieOptions); DateTimeOffset issuedUtc; if (signInContext.Properties.IssuedUtc.HasValue) { issuedUtc = signInContext.Properties.IssuedUtc.Value; } else { issuedUtc = Options.SystemClock.UtcNow; signInContext.Properties.IssuedUtc = issuedUtc; } if (!signInContext.Properties.ExpiresUtc.HasValue) { signInContext.Properties.ExpiresUtc = issuedUtc.Add(Options.ExpireTimeSpan); } await Options.Events.SigningIn(signInContext); if (signInContext.Properties.IsPersistent) { var expiresUtc = signInContext.Properties.ExpiresUtc ?? issuedUtc.Add(Options.ExpireTimeSpan); signInContext.CookieOptions.Expires = expiresUtc.ToUniversalTime(); } var ticket = new AuthenticationTicket(signInContext.Principal, signInContext.Properties, signInContext.AuthenticationScheme); if (Options.SessionStore != null) { if (_sessionKey != null) { await Options.SessionStore.RemoveAsync(_sessionKey); } _sessionKey = await Options.SessionStore.StoreAsync(ticket); var principal = new ClaimsPrincipal( new ClaimsIdentity( new[] { new Claim(SessionIdClaim, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) }, Options.ClaimsIssuer)); ticket = new AuthenticationTicket(principal, null, Options.AuthenticationScheme); } var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding()); Options.CookieManager.AppendResponseCookie( Context, Options.CookieName, cookieValue, signInContext.CookieOptions); var signedInContext = new CookieSignedInContext( Context, Options, Options.AuthenticationScheme, signInContext.Principal, signInContext.Properties); await Options.Events.SignedIn(signedInContext); // Only redirect on the login path var shouldRedirect = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath; await ApplyHeaders(shouldRedirect, signedInContext.Properties); } protected override async Task HandleSignOutAsync(SignOutContext signOutContext) { // Process the request cookie to initialize members like _sessionKey. var ticket = await EnsureCookieTicket(); var cookieOptions = BuildCookieOptions(); if (Options.SessionStore != null && _sessionKey != null) { await Options.SessionStore.RemoveAsync(_sessionKey); } var context = new CookieSigningOutContext( Context, Options, new AuthenticationProperties(signOutContext.Properties), cookieOptions); await Options.Events.SigningOut(context); Options.CookieManager.DeleteCookie( Context, Options.CookieName, context.CookieOptions); // Only redirect on the logout path var shouldRedirect = Options.LogoutPath.HasValue && OriginalPath == Options.LogoutPath; await ApplyHeaders(shouldRedirect, context.Properties); } private async Task ApplyHeaders(bool shouldRedirectToReturnUrl, AuthenticationProperties properties) { Response.Headers[HeaderNames.CacheControl] = HeaderValueNoCache; Response.Headers[HeaderNames.Pragma] = HeaderValueNoCache; Response.Headers[HeaderNames.Expires] = HeaderValueMinusOne; if (shouldRedirectToReturnUrl && Response.StatusCode == 200) { // set redirect uri in order: // 1. properties.RedirectUri // 2. query parameter ReturnUrlParameter // // Absolute uri is not allowed if it is from query string as query string is not // a trusted source. var redirectUri = properties.RedirectUri; if (string.IsNullOrEmpty(redirectUri)) { redirectUri = Request.Query[Options.ReturnUrlParameter]; if (string.IsNullOrEmpty(redirectUri) || !IsHostRelative(redirectUri)) { redirectUri = null; } } if (redirectUri != null) { await Options.Events.RedirectToReturnUrl( new CookieRedirectContext(Context, Options, redirectUri, properties)); } } } private static bool IsHostRelative(string path) { if (string.IsNullOrEmpty(path)) { return false; } if (path.Length == 1) { return path[0] == '/'; } return path[0] == '/' && path[1] != '/' && path[1] != '\\'; } protected override async Task HandleForbiddenAsync(ChallengeContext context) { var properties = new AuthenticationProperties(context.Properties); var returnUrl = properties.RedirectUri; if (string.IsNullOrEmpty(returnUrl)) { returnUrl = OriginalPathBase + Request.Path + Request.QueryString; } var accessDeniedUri = Options.AccessDeniedPath + QueryString.Create(Options.ReturnUrlParameter, returnUrl); var redirectContext = new CookieRedirectContext(Context, Options, BuildRedirectUri(accessDeniedUri), properties); await Options.Events.RedirectToAccessDenied(redirectContext); return true; } protected override async Task HandleUnauthorizedAsync(ChallengeContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } var properties = new AuthenticationProperties(context.Properties); var redirectUri = properties.RedirectUri; if (string.IsNullOrEmpty(redirectUri)) { redirectUri = OriginalPathBase + Request.Path + Request.QueryString; } var loginUri = Options.LoginPath + QueryString.Create(Options.ReturnUrlParameter, redirectUri); var redirectContext = new CookieRedirectContext(Context, Options, BuildRedirectUri(loginUri), properties); await Options.Events.RedirectToLogin(redirectContext); return true; } private string GetTlsTokenBinding() { var binding = Context.Features.Get()?.GetProvidedTokenBindingId(); return binding == null ? null : Convert.ToBase64String(binding); } } }