//****************************************************************************************************** // SessionHandler.cs - Gbtc // // Copyright © 2017, Grid Protection Alliance. All Rights Reserved. // // Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See // the NOTICE file distributed with this work for additional information regarding copyright ownership. // The GPA licenses this file to you under the MIT License (MIT), the "License"; you may // not use this file except in compliance with the License. You may obtain a copy of the License at: // // http://opensource.org/licenses/MIT // // Unless agreed to in writing, the subject software distributed under the License is distributed on an // "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the // License for the specific language governing permissions and limitations. // // Code Modification History: // ---------------------------------------------------------------------------------------------------- // 08/24/2017 - J. Ritchie Carroll // Generated original version of source code. // //****************************************************************************************************** using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Timers; using System.Web.Http; using GSF.Collections; using GSF.Configuration; using GSF.Security; using Microsoft.AspNet.SignalR; using Microsoft.Owin; using RazorEngine.Templating; using Timer = System.Timers.Timer; using Random = GSF.Security.Cryptography.Random; #pragma warning disable SG0015 // Validated - no hard-coded password present namespace GSF.Web.Security { /// /// Represents an HTTP messaging handler that can inject a session ID that can be used for security and user state. /// public class SessionHandler : DelegatingHandler { #region [ Members ] // Nested Types private class Session { public readonly DynamicViewBag State = new DynamicViewBag(); public DateTime LastAccess; } private class Credential { #region [ Constructors ] public Credential() { } // Note: Usage is dynamically called via FileBackedDictionary // ReSharper disable once UnusedMember.Local [SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times")] public Credential(Stream stream) { byte[] protectedCredentials; using (BinaryReader reader = new BinaryReader(stream, Encoding.Unicode, true)) { int length = reader.ReadInt32(); protectedCredentials = reader.ReadBytes(length); } byte[] credentials = ProtectedData.Unprotect(protectedCredentials, null, DataProtectionScope.LocalMachine); using (MemoryStream memStream = new MemoryStream(credentials)) using (BinaryReader memReader = new BinaryReader(memStream, Encoding.Unicode, true)) { Validator = memReader.ReadString(); Username = memReader.ReadString(); Password = memReader.ReadString(); Expiration = new DateTime(memReader.ReadInt64()); } } #endregion #region [ Properties ] public string Validator { get; set; } public string Username { get; set; } public string Password { get; set; } public DateTime Expiration { get; set; } #endregion #region [ Methods ] // Note: Usage is dynamically called via FileBackedDictionary // ReSharper disable once UnusedMember.Local [SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times")] public void WriteTo(Stream stream) { byte[] credentials; using (MemoryStream memStream = new MemoryStream()) using (BinaryWriter memWriter = new BinaryWriter(memStream, Encoding.Unicode, true)) { memWriter.Write(Validator); memWriter.Write(Username); memWriter.Write(Password); memWriter.Write(Expiration.Ticks); credentials = memStream.ToArray(); } byte[] protectedCredentials = ProtectedData.Protect(credentials, null, DataProtectionScope.LocalMachine); using (BinaryWriter writer = new BinaryWriter(stream, Encoding.Unicode, true)) { writer.Write(protectedCredentials.Length); writer.Write(protectedCredentials); } } #endregion } // Constants /// /// Default value for . /// public const double DefaultSessionTimeout = 20.0D; /// /// Default value for . /// public const double DefaultSessionMonitorInterval = 60000.0D; /// /// Default value for ; /// public const string DefaultAuthenticationToken = "x-gsf-auth"; /// /// Default value for ; /// public const string DefaultSessionToken = "x-gsf-session"; #endregion #region [ Constructors ] /// /// Creates a new . /// /// Token used for identifying the authentication token in cookie headers. /// Token used for identifying the session ID in cookie headers. public SessionHandler(string authenticationToken = DefaultAuthenticationToken, string sessionToken = DefaultSessionToken) { AuthenticationToken = authenticationToken; SessionToken = sessionToken; } #endregion #region [ Properties ] /// /// Gets the token used for identifying the authentication token in cookie headers. /// public string AuthenticationToken { get; } /// /// Gets the token used for identifying the session ID in cookie headers. /// public string SessionToken { get; } #endregion #region [ Methods ] /// /// Modifies the HTTP request message pipeline to include a session identifier. /// /// The HTTP request message to send to the server. /// A cancellation token to cancel operation. /// HTTP response message. protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // Get existing session data from the request, if any CookieHeaderValue cookie = request.Headers.GetCookies(SessionToken).FirstOrDefault(); string sessionCookieValue = cookie?[SessionToken].Value; Guid sessionID; // If session ID format is invalid, create a new one if (!Guid.TryParse(sessionCookieValue, out sessionID)) sessionID = Guid.NewGuid(); sessionCookieValue = sessionID.ToString(); // Save session ID (as Guid) in the request properties request.Properties[SessionToken] = sessionID; // Continue processing the HTTP request HttpResponseMessage response = await base.SendAsync(request, cancellationToken); // Store session ID in response message cookie response.Headers.AddCookies(new[] { new CookieHeaderValue(SessionToken, sessionCookieValue) { Path = "/" } }); // Get authentication options associated with this request ReadonlyAuthenticationOptions options = request.GetAuthenticationOptions(); // If requesting the AuthTest page using BASIC authentication, reissue the client's authentication token string authTestPage = options.AuthTestPage; if (request.RequestUri.LocalPath == authTestPage) { SecurityPrincipal securityPrincipal = request.GetRequestContext().Principal as SecurityPrincipal; SecurityIdentity securityIdentity = securityPrincipal?.Identity; ISecurityProvider securityProvider = securityIdentity?.Provider; string username = securityIdentity?.Name; string password = securityProvider?.Password; if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) { string authenticationToken = IssueAuthenticationToken(username, password); InvalidateAuthenticationToken(request); response.Headers.AddCookies(new[] { new CookieHeaderValue(AuthenticationToken, authenticationToken) { Path = request.RequestUri.LocalPath, MaxAge = TimeSpan.FromDays(30.0D) } }); } // AuthTest page should always have a valid request verification token request.ValidateRequestVerificationToken(options); } return response; } private string IssueAuthenticationToken(string username, string password) { byte[] buffer = new byte[9]; // Generate the selector for the token Random.GetBytes(buffer); string selector = Convert.ToBase64String(buffer); // Generate the validator for the token Random.GetBytes(buffer); string validator = Convert.ToBase64String(buffer); // Determine where the credential cache is located ConfigurationFile configFile = ConfigurationFile.Current; CategorizedSettingsElementCollection systemSettings = configFile.Settings["systemSettings"]; string configurationCachePath = systemSettings["ConfigurationCachePath"].Value; string credentialCachePath = Path.Combine(configurationCachePath, "CredentialCache.bin"); // Open the credential cache lock (s_credentialCacheLock) { using (FileBackedDictionary credentialCache = new FileBackedDictionary(credentialCachePath)) { // Clean out expired credentials before issuing a new one DateTime now = DateTime.UtcNow; List expiredSelectors = credentialCache .Where(kvp => now >= kvp.Value.Expiration) .Select(kvp => kvp.Key) .ToList(); foreach (string expiredSelector in expiredSelectors) credentialCache.Remove(expiredSelector); credentialCache.Compact(); // Enter the new token into the credential cache credentialCache[selector] = new Credential { Validator = validator, Username = username, Password = password, Expiration = DateTime.UtcNow.AddDays(30.0D) }; } } return $"{selector}:{validator}"; } private void InvalidateAuthenticationToken(HttpRequestMessage request) { // Get the authentication token provided by the client in the request message string authenticationToken = GetAuthenticationTokenFromCookie(request, AuthenticationToken); if ((object)authenticationToken == null) return; string selector = authenticationToken.Split(':')[0]; // Determine where the credential cache is located ConfigurationFile configFile = ConfigurationFile.Current; CategorizedSettingsElementCollection systemSettings = configFile.Settings["systemSettings"]; string configurationCachePath = systemSettings["ConfigurationCachePath"].Value; string credentialCachePath = Path.Combine(configurationCachePath, "CredentialCache.bin"); // Open the credential cache lock (s_credentialCacheLock) { using (FileBackedDictionary credentialCache = new FileBackedDictionary(credentialCachePath)) { credentialCache.Remove(selector); } } } #endregion #region [ Static ] // Static Events /// /// Raised when a client session is being expired. /// public static event EventHandler> SessionExpired; /// /// Occurs when a static method encounters an exception. /// public static event EventHandler> ProcessException; // Static Fields private static readonly Timer s_sessionCacheMonitor; private static readonly ConcurrentDictionary s_sessionCache; private static readonly object s_credentialCacheLock = new object(); // Static Constructor static SessionHandler() { CategorizedSettingsElementCollection settings = ConfigurationFile.Current.Settings["systemSettings"]; settings.Add("SessionTimeout", DefaultSessionTimeout, "The timeout, in minutes, for which inactive client sessions will be expired and removed from the cache."); settings.Add("SessionMonitorInterval", DefaultSessionMonitorInterval, "The interval, in milliseconds, over which the client session cache will be evaluated for expired sessions."); SessionTimeout = settings["SessionTimeout"].ValueAs(DefaultSessionTimeout); SessionMonitorInterval = settings["SessionMonitorInterval"].ValueAs(DefaultSessionMonitorInterval); s_sessionCacheMonitor = new Timer(SessionMonitorInterval); s_sessionCacheMonitor.Elapsed += s_sessionCacheMonitor_Elapsed; s_sessionCacheMonitor.Enabled = false; s_sessionCache = new ConcurrentDictionary(); } // Static Properties /// /// Gets timeout, in minutes, for which inactive client sessions will be expired and removed from the cache. /// public static double SessionTimeout { get; } /// /// Gets interval, in milliseconds, over which the client session cache will be evaluated for expired sessions. /// public static double SessionMonitorInterval { get; } // Static Methods /// /// Clears any cached session for the specified . /// /// Identifier of session to clear. /// true if session was found and cleared; otherwise, false. public static bool ClearSessionCache(Guid sessionID) { Session session; if (!s_sessionCache.TryRemove(sessionID, out session)) return false; OnSessionExpired(sessionID, session.State); return true; } /// /// Attempts to use the authentication token to retrieve the user's credentials from the credential cache. /// /// The token used to retrieve the user's credentials. /// The username of the user. /// THe user's password. /// True if the user's credentials were successfully retrieved; false otherwise. public static bool TryGetCachedCredentials(string authenticationToken, out string username, out string password) { try { // Parse the selector and validator from the authentication token string[] authenticationTokenParts = authenticationToken.Split(':'); if (authenticationTokenParts.Length != 2) { username = null; password = null; return false; } string selector = authenticationTokenParts[0]; string validator = authenticationTokenParts[1]; // Determine where the credential cache is located ConfigurationFile configFile = ConfigurationFile.Current; CategorizedSettingsElementCollection systemSettings = configFile.Settings["systemSettings"]; string configurationCachePath = systemSettings["ConfigurationCachePath"].Value; string credentialCachePath = Path.Combine(configurationCachePath, "CredentialCache.bin"); // Read the credential cache to retrieve the user's // credentials that were mapped to this authentication token lock (s_credentialCacheLock) { using (FileBackedDictionary credentialCache = new FileBackedDictionary(credentialCachePath)) { Credential credential; if (credentialCache.TryGetValue(selector, out credential) && validator == credential.Validator && DateTime.UtcNow < credential.Expiration) { username = credential.Username; password = credential.Password; return true; } } } } catch (Exception ex) { OnProcessException(ex); } username = null; password = null; return false; } /// /// Tries to get the session ID Guid as defined in the request properties dictionary. /// /// The target HTTP request message. /// Token used for identifying the session ID in properties dictionary. /// Validated session ID to return. /// true if session ID was successfully accessed; otherwise, false. public static bool TryGetSessionID(HttpRequestMessage request, string sessionToken, out Guid sessionID) { object result; if (request.Properties.TryGetValue(sessionToken ?? DefaultSessionToken, out result) && result is Guid) { sessionID = (Guid)result; return true; } sessionID = Guid.Empty; return false; } /// /// Tries to get the session state as defined in the request properties dictionary. /// /// The target HTTP request message. /// Token used for identifying the session ID in properties dictionary. /// Session state to return. /// true if session state was successfully accessed; otherwise, false. public static bool TryGetSessionState(HttpRequestMessage request, string sessionToken, out DynamicViewBag sessionState) { Guid sessionID; Session session; if (TryGetSessionID(request, sessionToken, out sessionID) && s_sessionCache.TryGetValue(sessionID, out session)) { sessionState = session.State; return true; } sessionState = null; return false; } /// /// Gets the authentication token as defined in the request cookie header values. /// /// The target HTTP request message. /// Token used for identifying the authentication token in cookie headers. /// Authentication token string, if defined; otherwise, null. public static string GetAuthenticationTokenFromCookie(HttpRequestMessage request, string authenticationToken) { CookieHeaderValue cookie = request.Headers.GetCookies(authenticationToken).FirstOrDefault(); return cookie?[authenticationToken ?? DefaultAuthenticationToken].Value; } /// /// Gets the authentication token as defined in the request cookie header values. /// /// The target Owin request. /// Token used for identifying the authentication token in cookie headers. /// Authentication token string, if defined; otherwise, null. public static string GetAuthenticationTokenFromCookie(IOwinRequest request, string authenticationToken) { return request?.Cookies?[authenticationToken ?? DefaultAuthenticationToken]; } /// /// Gets the session ID Guid as defined in the request cookie header values. /// /// The target HTTP request message. /// Token used for identifying the session ID in cookie headers. /// Session ID string, if defined; otherwise, null. public static Guid GetSessionIDFromCookie(HttpRequestMessage request, string sessionToken) { CookieHeaderValue cookie = request.Headers.GetCookies(sessionToken).FirstOrDefault(); string value = cookie?[sessionToken ?? DefaultSessionToken].Value; Guid sessionID; if (Guid.TryParse(value, out sessionID)) UpdateSession(sessionID); return sessionID; } /// /// Gets the session ID Guid as defined in the Owin cookie header values. /// /// The target Owin request. /// Token used for identifying the session ID in cookie headers. /// Session ID string, if defined; otherwise, null. public static Guid GetSessionIDFromCookie(IOwinRequest request, string sessionToken) { string value = request?.Cookies?[sessionToken ?? DefaultSessionToken]; Guid sessionID; if (Guid.TryParse(value, out sessionID)) UpdateSession(sessionID); return sessionID; } /// /// Gets the session ID Guid as defined in the SignalR cookie header values. /// /// The target SignalR request. /// Token used for identifying the session ID in cookie headers. /// Session ID string, if defined; otherwise, null. public static Guid GetSessionIDFromCookie(IRequest request, string sessionToken) { string value = request?.Cookies?[sessionToken ?? DefaultSessionToken].Value; Guid sessionID; if (Guid.TryParse(value, out sessionID)) UpdateSession(sessionID); return sessionID; } private static void UpdateSession(Guid sessionID) { // Get cached session for this request, creating it if necessary Session session = s_sessionCache.GetOrAdd(sessionID, id => new Session()); // Update the last access time for the session state - as long as user is making requests, session will persist session.LastAccess = DateTime.UtcNow; s_sessionCacheMonitor.Enabled = true; } private static void OnSessionExpired(Guid sessionID, DynamicViewBag sessionState) { SessionExpired?.Invoke(typeof(SessionHandler), new EventArgs(sessionID, sessionState)); } private static void OnProcessException(Exception ex) { ProcessException?.Invoke(typeof(SessionHandler), new EventArgs(ex)); } private static void s_sessionCacheMonitor_Elapsed(object sender, ElapsedEventArgs e) { // Check for expired client sessions foreach (KeyValuePair clientSession in s_sessionCache) { if ((DateTime.UtcNow - clientSession.Value.LastAccess).TotalMinutes > SessionTimeout) ClearSessionCache(clientSession.Key); } s_sessionCacheMonitor.Enabled = s_sessionCache.Count > 0; } #endregion } /// /// Represents extension functions for GSF authentication middle-ware. /// public static class HttpConfigurationExtensions { /// /// Adds session management to the HTTP configuration message handlers to enable GSF role-base security. /// /// Target instance. /// Authentication options. public static void EnableSessions(this HttpConfiguration httpConfig, AuthenticationOptions options) { if (!httpConfig.MessageHandlers.Any(handler => handler is SessionHandler)) httpConfig.MessageHandlers.Add(new SessionHandler(options.AuthenticationToken, options.SessionToken)); if (!httpConfig.Filters.Any(filter => filter.Instance is ValidateRequestVerificationTokenAttribute)) httpConfig.Filters.Add(new ValidateRequestVerificationTokenAttribute(true)); } /// /// Retrieves a read-only copy of the authentication options from the specified . /// /// The HTTP request. /// The authentication options. public static ReadonlyAuthenticationOptions GetAuthenticationOptions(this HttpRequestMessage request) { object value; if (request.Properties.TryGetValue("MS_OwinContext", out value)) { IOwinContext context = value as IOwinContext; if ((object)context != null && context.Environment.TryGetValue("AuthenticationOptions", out value)) return value as ReadonlyAuthenticationOptions; } return null; } /// /// Generates an anti-forgery token that can be manually added to an HTTP request header, /// e.g., from within an AJAX request. /// /// HTTP request message. /// Anti-forgery token to be added as an HTTP header value. public static string GenerateRequestVerficationHeaderToken(this HttpRequestMessage request) { string cookieToken, formToken; AntiForgery.GetTokens(request, null, out cookieToken, out formToken); return $"{cookieToken}:{formToken}"; } /// /// Adds a new anti-forgery request verification token to the headers of the specified . /// /// HTTP request message. public static void AddRequestVerificationHeaderToken(this HttpRequestMessage request) { request.Headers.Add( request.GetAuthenticationOptions().RequestVerificationToken, request.GenerateRequestVerficationHeaderToken()); } /// /// Validates that the anti-forgery request verification token value comes from the user who submitted the data. /// /// HTTP request message. /// Flag that determines if form validation should be used. public static void ValidateRequestVerificationToken(this HttpRequestMessage request, bool formValidation = false) { ValidateRequestVerificationToken(request, request.GetAuthenticationOptions(), formValidation); } /// /// Validates that the anti-forgery request verification token value comes from the user who submitted the data. /// /// HTTP request message. /// Read-only authentication options for this . /// Flag that determines if form validation should be used. public static void ValidateRequestVerificationToken(this HttpRequestMessage request, ReadonlyAuthenticationOptions options, bool formValidation = false) { if (formValidation) { // Form validation AntiForgery.Validate(request); } else { // Header validation string cookieToken = ""; string formToken = ""; IEnumerable tokenHeaders; if (request.Headers.TryGetValues(options.RequestVerificationToken, out tokenHeaders)) { string[] tokens = tokenHeaders.First().Split(':'); if (tokens.Length == 2) { cookieToken = tokens[0].Trim(); formToken = tokens[1].Trim(); } } AntiForgery.Validate(request, cookieToken, formToken); } } } }