/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs
https://github.com/aspnet/AspNetCore · C# · 430 lines · 315 code · 56 blank · 59 comment · 59 complexity · 7c17478b8ebb1754de1027403414423e MD5 · raw file
- // 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.Collections.Generic;
- using System.Diagnostics;
- using System.Linq;
- using System.Runtime.InteropServices;
- using System.Security.Claims;
- using System.Security.Principal;
- using System.Text.Encodings.Web;
- using System.Threading.Tasks;
- 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>
- /// <param name="options"></param>
- /// <param name="logger"></param>
- /// <param name="encoder"></param>
- /// <param name="clock"></param>
- public NegotiateHandler(IOptionsMonitor<NegotiateOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
- : base(options, logger, encoder, clock)
- { }
- /// <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>True if a response was generated, false otherwise.</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[HeaderNames.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;
- persistence.State = null;
- }
- _negotiateState ??= Options.StateFactory.CreateInstance();
- var outgoing = _negotiateState.GetOutgoingBlob(token, out var errorType, out var exception);
- if (errorType != BlobErrorType.None)
- {
- 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 Exception("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.
- 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 class AuthPersistence : IDisposable
- {
- internal INegotiateState State { get; set; }
- public void Dispose()
- {
- State?.Dispose();
- }
- }
- }
- }