//******************************************************************************************************
// 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);
}
}
}
}