//******************************************************************************************************
// AuthenticationHandler.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/25/2017 - Stephen C. Wills
// Generated original version of source code.
// 08/26/2017 - J. Ritchie Carroll
// Updated handling for anonymous requests and added principal lookup function for a session ID
// 08/28/2017 - J. Ritchie Carroll
// Improved NTLM pass-through authentication and unauthorized user handling
//
//******************************************************************************************************
using System;
using System.Collections.Concurrent;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Principal;
using System.Text;
using System.Threading.Tasks;
using GSF.Diagnostics;
using GSF.Reflection;
using GSF.Security;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Infrastructure;
#pragma warning disable SG0015 // Validated - no hard-coded password present
namespace GSF.Web.Security
{
///
/// Handles authentication using the configured implementation in the Owin pipeline.
///
public class AuthenticationHandler : AuthenticationHandler
{
#region [ Properties ]
// Reads the authorization header value from the request
private AuthenticationHeaderValue AuthorizationHeader
{
get
{
string[] authorization = Request.Headers["Authorization"]?.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (authorization is null || authorization.Length < 2)
return null;
return new AuthenticationHeaderValue(authorization[0], authorization[1]);
}
}
// Gets a principal that represents an unauthenticated anonymous user
private IPrincipal AnonymousPrincipal
{
get
{
IIdentity anonymousIdentity = new GenericIdentity("anonymous");
return new GenericPrincipal(anonymousIdentity, Array.Empty());
}
}
#endregion
#region [ Methods ]
///
/// The core authentication logic which must be provided by the handler. Will be invoked at most
/// once per request. Do not call directly, call the wrapping Authenticate method instead.
///
/// The ticket data provided by the authentication logic
protected override Task AuthenticateCoreAsync()
{
// Track original principal
Request.Environment["OriginalPrincipal"] = Request.User;
Request.Environment["AuthenticationOptions"] = Options.Readonly;
// No authentication required for anonymous resources
if (Options.IsAnonymousResource(Request.Path.Value))
return Task.FromResult(null);
NameValueCollection queryParameters = System.Web.HttpUtility.ParseQueryString(Request.QueryString.Value);
bool useAlternateSecurityProvider = Options.IsAlternateSecurityProviderResource(Request.Path.Value);
useAlternateSecurityProvider = useAlternateSecurityProvider || (Options.AuthTestPage == Request.Path.Value && Request.QueryString.HasValue && queryParameters.AllKeys.Contains("useAlternate"));
// Attempt to read the session ID from the HTTP cookies
Guid sessionID = SessionHandler.GetSessionIDFromCookie(Request, Options.SessionToken);
if (Request.Uri.LocalPath == Options.LogoutPage)
{
IIdentity logoutIdentity = new GenericIdentity(sessionID.ToString());
string[] logoutRoles = { "logout" };
Request.User = new GenericPrincipal(logoutIdentity, logoutRoles);
return Task.FromResult(null);
}
AuthenticationHeaderValue authorization = AuthorizationHeader;
// Attempt to retrieve the user's credentials that were cached to the user's session
if (TryGetPrincipal(sessionID, useAlternateSecurityProvider, out SecurityPrincipal securityPrincipal))
{
bool useCachedCredentials =
Request.User is null ||
Request.User.Identity.Name.Equals(securityPrincipal.Identity.Name, StringComparison.OrdinalIgnoreCase) ||
authorization?.Scheme != "Basic";
if (!useCachedCredentials)
{
// Explicit login attempts as a different user
// cause credentials to be flushed from the session
ClearAuthorizationCache(sessionID);
securityPrincipal = null;
}
}
if (authorization is null && securityPrincipal is null)
{
// Attempt to authenticate using cached credentials associated with the authentication token cookie
string authenticationToken = SessionHandler.GetAuthenticationTokenFromCookie(Request, Options.AuthenticationToken);
securityPrincipal = AuthenticateCachedCredentials(authenticationToken, useAlternateSecurityProvider);
// If authentication using cached credentials fails,
// fall back on the other authentication methods
if (securityPrincipal?.Identity.IsAuthenticated != true)
securityPrincipal = null;
// Attempt to cache the security principal to the session
if (sessionID != Guid.Empty && securityPrincipal?.Identity.IsAuthenticated == true)
CachePrincipal(sessionID, securityPrincipal, useAlternateSecurityProvider);
}
if (securityPrincipal is null)
{
// Pick the appropriate authentication logic based
// on the authorization type in the HTTP headers
// or in the URI Parameters if it is using OIDC.
if (authorization?.Scheme == "Basic")
securityPrincipal = AuthenticateBasic(authorization.Parameter, useAlternateSecurityProvider);
// If the resources contains a code make an Attempt to Authorize via OIDC Auth server
else if (Request.QueryString.HasValue && queryParameters.AllKeys.Contains("code"))
securityPrincipal = AuthenticateCode(useAlternateSecurityProvider);
else
securityPrincipal = AuthenticatePassthrough(useAlternateSecurityProvider);
// Attempt to cache the security principal to the session
if (sessionID != Guid.Empty && securityPrincipal?.Identity.IsAuthenticated == true)
CachePrincipal(sessionID, securityPrincipal, useAlternateSecurityProvider);
}
// Set the principal of the IOwinRequest so that it
// can be propagated through the Owin pipeline
Request.User = securityPrincipal ?? AnonymousPrincipal;
return Task.FromResult(null);
}
///
/// Called once by common code after initialization. If an authentication middle-ware
/// responds directly to specifically known paths it must override this virtual,
/// compare the request path to it's known paths, provide any response information
/// as appropriate, and true to stop further processing.
///
///
/// Returning false will cause the common code to call the next middle-ware in line.
/// Returning true will cause the common code to begin the async completion journey
/// without calling the rest of the middle-ware pipeline.
///
public override async Task InvokeAsync()
{
// Use Cases:
//
// (1) Access resource marked as anonymous - let pipeline continue
// (2) Access resource as authenticated user - let pipeline continue
// --- remaining use cases are unauthorized ---
// (3) Access resource as result of a redirect from the OIDC Auth Server
// (4) Access resource marked for auth failure redirection - respond with 302 and abort pipeline
// (5) Access all other resources - respond with 401 and abort pipeline
SecurityPrincipal securityPrincipal = Request.User as SecurityPrincipal;
string urlPath = Request.Path.Value;
// If the resources contains a code make an Attempt to Authorize via OIDC Auth server
NameValueCollection queryParameters = System.Web.HttpUtility.ParseQueryString(Request.QueryString.Value);
bool useAlternateSecurityProvider = Options.IsAlternateSecurityProviderResource(Request.Path.Value);
useAlternateSecurityProvider = useAlternateSecurityProvider || (Options.AuthTestPage == Request.Path.Value && Request.QueryString.HasValue && queryParameters.AllKeys.Contains("useAlternate"));
if (Request.User is not null && Request.User.IsInRole("logout"))
{
string identityName = Request.User.Identity.Name;
string bodyMessage = Guid.TryParse(identityName, out Guid sessionID) && LogOut(sessionID)
? "Logout complete"
: "Unable to locate the user session";
using TextWriter writer = new StreamWriter(Response.Body, Encoding.UTF8, 4096, true);
await writer.WriteAsync(bodyMessage);
Response.StatusCode = 200;
return true; // Abort pipeline
}
// If the user is properly Authenticated but a redirect is requested send that redirect
if (securityPrincipal?.Identity.IsAuthenticated == true && securityPrincipal.Identity.Provider.IsRedirectRequested)
{
Response.Redirect(securityPrincipal.Identity.Provider.RequestedRedirect ?? "/");
return true;
}
// If request is for an anonymous resource or user is properly authenticated, allow
// request to propagate through the Owin pipeline
if (Options.IsAnonymousResource(urlPath) || securityPrincipal?.Identity.IsAuthenticated == true)
return false; // Let pipeline continue
// Abort pipeline with appropriate response
if (Options.IsAuthFailureRedirectResource(urlPath) && !IsAjaxCall())
{
byte[] pathBytes;
string base64Path, encodedPath, referrer = null;
if (Request.Headers.TryGetValue("Referer", out string[] values) && values.Length == 1)
{
pathBytes = Encoding.UTF8.GetBytes(values[0]);
base64Path = Convert.ToBase64String(pathBytes);
encodedPath = WebUtility.UrlEncode(base64Path);
referrer = $"&referrer={encodedPath}";
if (useAlternateSecurityProvider)
referrer = referrer + "&useAlternateSecurityProvider=1";
}
else if (useAlternateSecurityProvider)
referrer = "&useAlternateSecurityProvider=1";
string urlQueryString = Request.QueryString.HasValue ? "?" + Request.QueryString.Value : "";
pathBytes = Encoding.UTF8.GetBytes(urlPath + urlQueryString);
base64Path = Convert.ToBase64String(pathBytes);
encodedPath = WebUtility.UrlEncode(base64Path);
ISecurityProvider securityProvider = securityPrincipal?.Identity?.Provider;
if (securityProvider == null)
securityProvider = SecurityProviderCache.CreateProvider("", autoRefresh: false, useAlternate: useAlternateSecurityProvider);
Response.Redirect(securityProvider.TranslateRedirect(Options.LoginPage, Request.Uri, encodedPath, referrer));
}
else
{
Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
// Add current identity to unauthorized response header
string currentIdentity = securityPrincipal?.Identity.Name ?? "anonymous";
if (Request.Environment.TryGetValue("OriginalPrincipal", out object value))
{
if (value is IPrincipal originalPrincpal && originalPrincpal.Identity is not null)
currentIdentity = AdjustedUserName(originalPrincpal.Identity.Name);
}
bool debuggable = Common.GetApplicationType() != ApplicationType.Web && AssemblyInfo.EntryAssembly.Debuggable;
Response.Headers.Add("CurrentIdentity", new[] { currentIdentity });
Response.ReasonPhrase = SecurityPrincipal.GetFailureReasonPhrase(securityPrincipal, AuthorizationHeader?.Scheme, debuggable);
string failureReason = SecurityPrincipal.GetFailureReasonPhrase(securityPrincipal, AuthorizationHeader?.Scheme, true);
Log.Publish(MessageLevel.Info, "AuthenticationFailure", $"Failed to authenticate {currentIdentity} for {Request.Path}: {failureReason}");
return true; // Abort pipeline
}
private bool LogOut(Guid sessionID)
{
// Flush any cached information that has been saved for this user including anything saved in the alternate SecurityProvider Cache
if (TryGetPrincipal(sessionID, true, out SecurityPrincipal securityPrincipal))
SecurityProviderCache.Flush(securityPrincipal.Identity.Name, true);
if (TryGetPrincipal(sessionID, false, out securityPrincipal))
SecurityProviderCache.Flush(securityPrincipal.Identity.Name);
// Clear any cached session state for user (this also clears any cached authorizations)
return SessionHandler.ClearSessionCache(sessionID);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsAjaxCall() =>
Request.Headers.TryGetValue("X-Requested-With", out string[] values) && values.Any(value => value.Equals("XMLHttpRequest"));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private string AdjustedUserName(string username)
{
int index = username.IndexOf('\\');
if (index < 1)
return username;
string[] parts = username.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
return username;
return parts[0].Trim().Equals(Environment.MachineName) ? parts[1].Trim() : username;
}
// Applies authentication for requests where credentials are passed directly in the HTTP headers.
private SecurityPrincipal AuthenticateCachedCredentials(string authenticationToken, bool useAlternateSecurityProvider)
{
if (authenticationToken is null)
return null;
// Get the user's credentials from the credential cache
if (!SessionHandler.TryGetCachedCredentials(authenticationToken, out string username, out string password))
return null;
// Create the security provider that will authenticate the user's credentials
ISecurityProvider securityProvider = SecurityProviderCache.CreateProvider(username, autoRefresh: false,useAlternate: useAlternateSecurityProvider);
securityProvider.Password = password;
securityProvider.Authenticate();
// Return the security principal that will be used for role-based authorization
SecurityIdentity securityIdentity = new SecurityIdentity(securityProvider);
return new SecurityPrincipal(securityIdentity);
}
// Applies authentication for requests where credentials are passed directly in the HTTP headers.
private SecurityPrincipal AuthenticateBasic(string credentials, bool useAlternateSecurityProvider)
{
// Get the user's credentials from the HTTP headers
if (!TryParseCredentials(credentials, out string username, out string password))
return null;
// Create the security provider that will authenticate the user's credentials
ISecurityProvider securityProvider = SecurityProviderCache.CreateProvider(username, autoRefresh: false,useAlternate: useAlternateSecurityProvider);
securityProvider.Password = password;
securityProvider.Authenticate();
// Return the security principal that will be used for role-based authorization
SecurityIdentity securityIdentity = new SecurityIdentity(securityProvider);
return new SecurityPrincipal(securityIdentity);
}
// Applies authentication for requests using Windows pass-through authentication.
private SecurityPrincipal AuthenticatePassthrough(bool useAlternateSecurityProvider)
{
string username = Request.User?.Identity.Name;
if (username is null)
return null;
// Get the principal used for verifying the user's pass-through authentication
IPrincipal passthroughPrincipal = Request.User;
// Create the security provider that will verify the user's pass-through authentication
ISecurityProvider securityProvider = SecurityProviderCache.CreateProvider(username, passthroughPrincipal, false, useAlternateSecurityProvider);
securityProvider.Authenticate();
// Return the security principal that will be used for role-based authorization
SecurityIdentity securityIdentity = new SecurityIdentity(securityProvider);
return new SecurityPrincipal(securityIdentity);
}
///
/// Applies authentication for requests using OpenID Connect authentication.
///
/// Indicates whether the alternate should be used
/// The .
private SecurityPrincipal AuthenticateCode(bool useAlternateSecurityProvider)
{
string username = System.Web.HttpUtility.ParseQueryString(Request.QueryString.Value).Get("code");
if (string.IsNullOrEmpty(username))
return null;
// Create the security provider that will verify the user's pass-through authentication
ISecurityProvider securityProvider = SecurityProviderCache.CreateProvider(username, null, false, useAlternateSecurityProvider);
securityProvider.Authenticate();
// Return the security principal that will be used for role-based authorization
SecurityIdentity securityIdentity = new SecurityIdentity(securityProvider);
return new SecurityPrincipal(securityIdentity);
}
#endregion
#region [ Static ]
// Static Fields
private static readonly LogPublisher Log = Logger.CreatePublisher(typeof(AuthenticationHandler), MessageClass.Framework);
private static readonly ConcurrentDictionary s_authorizationCache;
private static readonly ConcurrentDictionary s_alternateAuthorizationCache;
// Static Constructor
static AuthenticationHandler()
{
s_authorizationCache = new ConcurrentDictionary();
s_alternateAuthorizationCache = new ConcurrentDictionary();
// Attach to session expiration event so any cached authorizations can also be cleared
SessionHandler.SessionExpired += (_, e) => ClearAuthorizationCache(e.Argument1);
}
// Static Methods
///
/// Attempt to get current security principal for specified .
///
/// Session ID to user.
/// Principal of user with specified , if found.
/// Indicate when to useSecurity Principles from alternate SecurityProvider
/// true if principal was found for specified ; otherwise, false.
public static bool TryGetPrincipal(Guid sessionID, bool useAlternate, out SecurityPrincipal securityPrincipal) =>
useAlternate ?
s_alternateAuthorizationCache.TryGetValue(sessionID, out securityPrincipal) :
s_authorizationCache.TryGetValue(sessionID, out securityPrincipal);
///
/// Clears any cached authorizations for the specified .
///
/// Identifier of session authorization to clear.
/// true if session authorization was found and cleared; otherwise, false.
public static bool ClearAuthorizationCache(Guid sessionID)
{
bool removed = false, removedAlternate = false;
if (s_alternateAuthorizationCache.TryRemove(sessionID, out SecurityPrincipal ssecurityPrincipalAlternate))
{
SecurityProviderCache.DisableAutoRefresh(ssecurityPrincipalAlternate.Identity.Provider, true);
removedAlternate = true;
}
if (s_authorizationCache.TryRemove(sessionID, out SecurityPrincipal securityPrincipal))
{
SecurityProviderCache.DisableAutoRefresh(securityPrincipal.Identity.Provider);
removed = true;
}
return removed || removedAlternate;
}
private static void CachePrincipal(Guid sessionID, SecurityPrincipal principal, bool useAlternateSecurityProvider)
{
if (useAlternateSecurityProvider && s_alternateAuthorizationCache.TryAdd(sessionID, principal))
SecurityProviderCache.AutoRefresh(principal.Identity.Provider, true);
if (!useAlternateSecurityProvider && s_authorizationCache.TryAdd(sessionID, principal))
SecurityProviderCache.AutoRefresh(principal.Identity.Provider);
}
private static bool TryParseCredentials(string authorizationParameter, out string userName, out string password)
{
byte[] credentialBytes;
userName = null;
password = null;
try
{
credentialBytes = Convert.FromBase64String(authorizationParameter);
}
catch (FormatException)
{
return false;
}
// The currently approved HTTP 1.1 specification says characters here are ISO-8859-1.
// However, the current draft updated specification for HTTP 1.1 indicates this
// encoding is infrequently used in practice and defines behavior only for ASCII.
// Make a writable copy of the ASCII encoding to enable setting the decoder fall-back
if (Encoding.ASCII.Clone() is not Encoding encoding)
return false;
// Fail on invalid bytes rather than silently replacing and continuing
encoding.DecoderFallback = DecoderFallback.ExceptionFallback;
string credentials;
try
{
credentials = encoding.GetString(credentialBytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (string.IsNullOrEmpty(credentials))
return false;
int index = credentials.IndexOf(':');
if (index == -1)
return false;
userName = credentials.Substring(0, index);
password = credentials.Substring(index + 1);
return true;
}
#endregion
}
}