File: NegotiateHandler.cs
Web Access
Project: src\src\Security\Authentication\Negotiate\src\Microsoft.AspNetCore.Authentication.Negotiate.csproj (Microsoft.AspNetCore.Authentication.Negotiate)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Connections.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
 
namespace Microsoft.AspNetCore.Authentication.Negotiate;
 
/// <summary>
/// Authenticates requests using Negotiate, Kerberos, or NTLM.
/// </summary>
public class NegotiateHandler : AuthenticationHandler<NegotiateOptions>, IAuthenticationRequestHandler
{
    private const string AuthPersistenceKey = nameof(AuthPersistence);
    private const string NegotiateVerb = "Negotiate";
    private const string AuthHeaderPrefix = NegotiateVerb + " ";
 
    private bool _requestProcessed;
    private INegotiateState? _negotiateState;
 
    /// <summary>
    /// Creates a new <see cref="NegotiateHandler"/>
    /// </summary>
    /// <inheritdoc />
    [Obsolete("ISystemClock is obsolete, use TimeProvider on AuthenticationSchemeOptions instead.")]
    public NegotiateHandler(IOptionsMonitor<NegotiateOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    { }
 
    /// <summary>
    /// Creates a new <see cref="NegotiateHandler"/>
    /// </summary>
    /// <inheritdoc />
    public NegotiateHandler(IOptionsMonitor<NegotiateOptions> options, ILoggerFactory logger, UrlEncoder encoder)
        : base(options, logger, encoder)
    { }
 
    /// <summary>
    /// The handler calls methods on the events which give the application control at certain points where processing is occurring.
    /// If it is not provided a default instance is supplied which does nothing when the methods are called.
    /// </summary>
    protected new NegotiateEvents Events
    {
        get => (NegotiateEvents)base.Events!;
        set => base.Events = value;
    }
 
    /// <summary>
    /// Creates the default events type.
    /// </summary>
    /// <returns></returns>
    protected override Task<object> CreateEventsAsync() => Task.FromResult<object>(new NegotiateEvents());
 
    private bool IsSupportedProtocol => HttpProtocol.IsHttp11(Request.Protocol) || HttpProtocol.IsHttp10(Request.Protocol);
 
    /// <summary>
    /// Intercepts incomplete Negotiate authentication handshakes and continues or completes them.
    /// </summary>
    /// <returns><see langword="true" /> if a response was generated, otherwise <see langword="false"/>.</returns>
    public async Task<bool> HandleRequestAsync()
    {
        AuthPersistence? persistence = null;
        bool authFailedEventCalled = false;
        try
        {
            if (_requestProcessed || Options.DeferToServer)
            {
                // This request was already processed but something is re-executing it like an exception handler.
                // Don't re-run because we could corrupt the connection state, e.g. if this was a stage2 NTLM request
                // that we've already completed the handshake for.
                // Or we're in deferral mode where we let the server handle the authentication.
                return false;
            }
 
            _requestProcessed = true;
 
            if (!IsSupportedProtocol)
            {
                // HTTP/1.0 and HTTP/1.1 are supported. Do not throw because this may be running on a server that supports
                // additional protocols.
                return false;
            }
 
            var connectionItems = GetConnectionItems();
            persistence = (AuthPersistence)connectionItems[AuthPersistenceKey]!;
            _negotiateState = persistence?.State;
 
            var authorizationHeader = Request.Headers.Authorization;
 
            if (StringValues.IsNullOrEmpty(authorizationHeader))
            {
                if (_negotiateState?.IsCompleted == false)
                {
                    throw new InvalidOperationException("An anonymous request was received in between authentication handshake requests.");
                }
                return false;
            }
 
            var authorization = authorizationHeader.ToString();
            string? token = null;
            if (authorization.StartsWith(AuthHeaderPrefix, StringComparison.OrdinalIgnoreCase))
            {
                token = authorization.Substring(AuthHeaderPrefix.Length).Trim();
            }
            else
            {
                if (_negotiateState?.IsCompleted == false)
                {
                    throw new InvalidOperationException("Non-negotiate request was received in between authentication handshake requests.");
                }
                return false;
            }
 
            // WinHttpHandler re-authenticates an existing connection if it gets another challenge on subsequent requests.
            if (_negotiateState?.IsCompleted == true)
            {
                Logger.Reauthenticating();
                _negotiateState.Dispose();
                _negotiateState = null;
                if (persistence != null)
                {
                    persistence.State = null;
                }
            }
 
            _negotiateState ??= Options.StateFactory.CreateInstance();
 
            var outgoing = _negotiateState.GetOutgoingBlob(token, out var errorType, out var exception);
            if (errorType != BlobErrorType.None)
            {
                Debug.Assert(exception != null);
 
                Logger.NegotiateError(errorType.ToString());
                _negotiateState.Dispose();
                _negotiateState = null;
                if (persistence?.State != null)
                {
                    persistence.State.Dispose();
                    persistence.State = null;
                }
 
                if (errorType == BlobErrorType.CredentialError)
                {
                    Logger.CredentialError(exception);
                    authFailedEventCalled = true; // Could throw, and we don't want to double trigger the event.
                    var result = await InvokeAuthenticateFailedEvent(exception);
                    return result ?? false; // Default to skipping the handler, let AuthZ generate a new 401
                }
                else if (errorType == BlobErrorType.ClientError)
                {
                    Logger.ClientError(exception);
                    authFailedEventCalled = true; // Could throw, and we don't want to double trigger the event.
                    var result = await InvokeAuthenticateFailedEvent(exception);
                    if (result.HasValue)
                    {
                        return result.Value;
                    }
                    Context.Response.StatusCode = StatusCodes.Status400BadRequest;
                    return true; // Default to terminating request
                }
 
                throw exception;
            }
 
            if (!_negotiateState.IsCompleted)
            {
                persistence ??= EstablishConnectionPersistence(connectionItems);
                // Save the state long enough to complete the multi-stage handshake.
                // We'll remove it once complete if !PersistNtlm/KerberosCredentials.
                persistence.State = _negotiateState;
 
                Logger.IncompleteNegotiateChallenge();
                Response.StatusCode = StatusCodes.Status401Unauthorized;
                Response.Headers.Append(HeaderNames.WWWAuthenticate, AuthHeaderPrefix + outgoing);
                return true;
            }
 
            Logger.NegotiateComplete();
 
            // There can be a final blob of data we need to send to the client, but let the request execute as normal.
            if (!string.IsNullOrEmpty(outgoing))
            {
                Response.OnStarting(() =>
                {
                    // Only include it if the response ultimately succeeds. This avoids adding it twice if Challenge is called again.
                    if (Response.StatusCode < StatusCodes.Status400BadRequest)
                    {
                        Response.Headers.Append(HeaderNames.WWWAuthenticate, AuthHeaderPrefix + outgoing);
                    }
                    return Task.CompletedTask;
                });
            }
 
            // Deal with connection credential persistence.
 
            if (_negotiateState.Protocol == "NTLM" && !Options.PersistNtlmCredentials)
            {
                // NTLM was already put in the persitence cache on the prior request so we could complete the handshake.
                // Take it out if we don't want it to persist.
                Debug.Assert(object.ReferenceEquals(persistence?.State, _negotiateState),
                    "NTLM is a two stage process, it must have already been in the cache for the handshake to succeed.");
                Logger.DisablingCredentialPersistence(_negotiateState.Protocol);
                persistence.State = null;
                Response.RegisterForDispose(_negotiateState);
            }
            else if (_negotiateState.Protocol == "Kerberos")
            {
                // Kerberos can require one or two stage handshakes
                if (Options.PersistKerberosCredentials)
                {
                    Logger.EnablingCredentialPersistence();
                    persistence ??= EstablishConnectionPersistence(connectionItems);
                    persistence.State = _negotiateState;
                }
                else
                {
                    if (persistence?.State != null)
                    {
                        Logger.DisablingCredentialPersistence(_negotiateState.Protocol);
                        persistence.State = null;
                    }
                    Response.RegisterForDispose(_negotiateState);
                }
            }
 
            // Note we run the Authenticated event in HandleAuthenticateAsync so it is per-request rather than per connection.
        }
        catch (Exception ex)
        {
            if (authFailedEventCalled)
            {
                throw;
            }
 
            Logger.ExceptionProcessingAuth(ex);
 
            // Clear state so it's possible to retry on the same connection.
            _negotiateState?.Dispose();
            _negotiateState = null;
            if (persistence?.State != null)
            {
                persistence.State.Dispose();
                persistence.State = null;
            }
 
            var result = await InvokeAuthenticateFailedEvent(ex);
            if (result.HasValue)
            {
                return result.Value;
            }
 
            throw;
        }
 
        return false;
    }
 
    private async Task<bool?> InvokeAuthenticateFailedEvent(Exception ex)
    {
        var errorContext = new AuthenticationFailedContext(Context, Scheme, Options) { Exception = ex };
        await Events.AuthenticationFailed(errorContext);
 
        if (errorContext.Result != null)
        {
            if (errorContext.Result.Handled)
            {
                return true;
            }
            else if (errorContext.Result.Skipped)
            {
                return false;
            }
            else if (errorContext.Result.Failure != null)
            {
                throw new AuthenticationFailureException("An error was returned from the AuthenticationFailed event.", errorContext.Result.Failure);
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Checks if the current request is authenticated and returns the user.
    /// </summary>
    /// <returns></returns>
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!_requestProcessed)
        {
            throw new InvalidOperationException("AuthenticateAsync must not be called before the UseAuthentication middleware runs.");
        }
 
        if (!IsSupportedProtocol)
        {
            // Not supported. We don't throw because Negotiate may be set as the default auth
            // handler on a server that's running HTTP/1 and HTTP/2. We'll challenge HTTP/2 requests
            // that require auth and they'll downgrade to HTTP/1.1.
            Logger.ProtocolNotSupported(Request.Protocol);
            return AuthenticateResult.NoResult();
        }
 
        if (_negotiateState == null)
        {
            return AuthenticateResult.NoResult();
        }
 
        if (!_negotiateState.IsCompleted)
        {
            // This case should have been rejected by HandleRequestAsync
            throw new InvalidOperationException("Attempting to use an incomplete authentication context.");
        }
 
        // Make a new copy of the user for each request, they are mutable objects and
        // things like ClaimsTransformation run per request.
        var identity = _negotiateState.GetIdentity();
        ClaimsPrincipal user;
        if (OperatingSystem.IsWindows() && identity is WindowsIdentity winIdentity)
        {
            user = new WindowsPrincipal(winIdentity);
            Response.RegisterForDispose(winIdentity);
        }
        else
        {
            user = new ClaimsPrincipal(new ClaimsIdentity(identity));
        }
 
        AuthenticatedContext authenticatedContext;
 
        if (Options.LdapSettings.EnableLdapClaimResolution)
        {
            var ldapContext = new LdapContext(Context, Scheme, Options, Options.LdapSettings)
            {
                Principal = user
            };
 
            await Events.RetrieveLdapClaims(ldapContext);
 
            if (ldapContext.Result != null)
            {
                return ldapContext.Result;
            }
 
            await LdapAdapter.RetrieveClaimsAsync(ldapContext.LdapSettings, (ldapContext.Principal.Identity as ClaimsIdentity)!, Logger);
 
            authenticatedContext = new AuthenticatedContext(Context, Scheme, Options)
            {
                Principal = ldapContext.Principal
            };
        }
        else
        {
            authenticatedContext = new AuthenticatedContext(Context, Scheme, Options)
            {
                Principal = user
            };
        }
 
        await Events.Authenticated(authenticatedContext);
 
        if (authenticatedContext.Result != null)
        {
            return authenticatedContext.Result;
        }
 
        var ticket = new AuthenticationTicket(authenticatedContext.Principal, authenticatedContext.Properties, Scheme.Name);
        return AuthenticateResult.Success(ticket);
    }
 
    /// <summary>
    /// Issues a 401 WWW-Authenticate Negotiate challenge.
    /// </summary>
    /// <param name="properties"></param>
    /// <returns></returns>
    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        // We allow issuing a challenge from an HTTP/2 request. Browser clients will gracefully downgrade to HTTP/1.1.
        // SocketHttpHandler will not downgrade (https://github.com/dotnet/corefx/issues/35195), but WinHttpHandler will.
        var eventContext = new ChallengeContext(Context, Scheme, Options, properties);
        await Events.Challenge(eventContext);
        if (eventContext.Handled)
        {
            return;
        }
 
        Response.StatusCode = StatusCodes.Status401Unauthorized;
        Response.Headers.Append(HeaderNames.WWWAuthenticate, NegotiateVerb);
        Logger.ChallengeNegotiate();
    }
 
    private AuthPersistence EstablishConnectionPersistence(IDictionary<object, object?> items)
    {
        Debug.Assert(!items.ContainsKey(AuthPersistenceKey), "This should only be registered once per connection");
        var persistence = new AuthPersistence();
        RegisterForConnectionDispose(persistence);
        items[AuthPersistenceKey] = persistence;
        return persistence;
    }
 
    private IDictionary<object, object?> GetConnectionItems()
    {
        return Context.Features.Get<IConnectionItemsFeature>()?.Items
            ?? throw new NotSupportedException($"Negotiate authentication requires a server that supports {nameof(IConnectionItemsFeature)} like Kestrel.");
    }
 
    private void RegisterForConnectionDispose(IDisposable authState)
    {
        var connectionCompleteFeature = Context.Features.Get<IConnectionCompleteFeature>()
            ?? throw new NotSupportedException($"Negotiate authentication requires a server that supports {nameof(IConnectionCompleteFeature)} like Kestrel.");
        connectionCompleteFeature.OnCompleted(DisposeState, authState);
    }
 
    private static Task DisposeState(object state)
    {
        ((IDisposable)state).Dispose();
        return Task.CompletedTask;
    }
 
    // This allows us to have one disposal registration per connection and limits churn on the Items collection.
    private sealed class AuthPersistence : IDisposable
    {
        internal INegotiateState? State { get; set; }
 
        public void Dispose()
        {
            State?.Dispose();
        }
    }
}