| File: NegotiateHandler.cs | Web Access |
| Project: src\aspnetcore\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; 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 persistence 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(); } } }