From f348163bdca996645ffbc5d47dee3d908eff640c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 26 Jul 2022 23:04:57 +0200 Subject: [PATCH] [Mono.Android] Optional NTLMv2 support in AndroidMessageHandler (#6999) Context: https://github.com/dotnet/runtime/issues/62264 Context? https://github.com/wfurt/Ntlm Update `Xamarin.Android.Net.AndroidMessageHandler` to *optionally* support NTLMv2 authentication in .NET 7+. This authentication method is recommended only for legacy services that do not provide any more secure options. If an endpoint requires NTLMv2 authentication and NTMLv2 is not enabled, then the endpoint will return HTTP-401 errors. NTLMv2 authentication can be enabled by setting the `$(AndroidUseNegotiateAuthentication)` MSBuild property to True. If this property is False or isn't set, then NTLMv2 support is linked away during the package build. Example `.csproj` changes to enable NTLMv2 support: true Example C# `HttpClient` usage using NTLMv2 authentication: var cache = new CredentialCache (); cache.Add (serverUri, "Negotiate", new NetworkCredential(username, password, domain)); var handler = new AndroidMessageHandler { Credentials = cache, }; var client = new HttpClient (handler); var response = await client.GetAsync (requestUri); // 200 OK; 401 is NTLMv2 isn't enabled --- .../guides/building-apps/build-properties.md | 6 + .../ILLink/ILLink.Substitutions.xml | 4 + src/Mono.Android/Mono.Android.csproj | 1 + .../AndroidMessageHandler.cs | 74 ++++-- .../NegotiateAuthenticationHelper.cs | 210 ++++++++++++++++++ ...soft.Android.Sdk.DefaultProperties.targets | 1 + ...icrosoft.Android.Sdk.RuntimeConfig.targets | 8 + .../Tasks/LinkerTests.cs | 36 +++ .../Mono.Android-Test.Shared.projitems | 1 + .../Mono.Android.NET-Tests.csproj | 1 + ...sageHandlerNegotiateAuthenticationTests.cs | 157 +++++++++++++ 11 files changed, 480 insertions(+), 19 deletions(-) create mode 100644 src/Mono.Android/Xamarin.Android.Net/NegotiateAuthenticationHelper.cs create mode 100644 tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerNegotiateAuthenticationTests.cs diff --git a/Documentation/guides/building-apps/build-properties.md b/Documentation/guides/building-apps/build-properties.md index 843d9871c23..eb1c02ad601 100644 --- a/Documentation/guides/building-apps/build-properties.md +++ b/Documentation/guides/building-apps/build-properties.md @@ -1328,6 +1328,12 @@ than `aapt`. Added in Xamarin.Android 8.1. +## AndroidUseNegotiateAuthentication + +A boolean property which enables support for NTLM/Negotiate authentication in `AndroidMessageHandler`. The feature is disabled by default. + +Support for this property was added in .NET 7 and has no effect in "legacy" Xamarin.Android. + ## AndroidUseSharedRuntime A boolean property that diff --git a/src/Mono.Android/ILLink/ILLink.Substitutions.xml b/src/Mono.Android/ILLink/ILLink.Substitutions.xml index 96dff01b2b6..55ac10132dc 100644 --- a/src/Mono.Android/ILLink/ILLink.Substitutions.xml +++ b/src/Mono.Android/ILLink/ILLink.Substitutions.xml @@ -8,5 +8,9 @@ + + + + diff --git a/src/Mono.Android/Mono.Android.csproj b/src/Mono.Android/Mono.Android.csproj index 8aaf7d931fe..47c4842f26f 100644 --- a/src/Mono.Android/Mono.Android.csproj +++ b/src/Mono.Android/Mono.Android.csproj @@ -370,6 +370,7 @@ + diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index fc446353310..439a9083e40 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -202,7 +202,7 @@ public int MaxAutomaticRedirections /// /// The pre authentication data. public AuthenticationData? PreAuthenticationData { get; set; } - + /// /// If the website requires authentication, this property will contain data about each scheme supported /// by the server after the response. Note that unauthorized request will return a valid response - you @@ -234,12 +234,12 @@ public bool RequestNeedsAuthorization { /// /// /// If the request is to the server protected with a self-signed (or otherwise untrusted) SSL certificate, the request will - /// fail security chain verification unless the application provides either the CA certificate of the entity which issued the + /// fail security chain verification unless the application provides either the CA certificate of the entity which issued the /// server's certificate or, alternatively, provides the server public key. Whichever the case, the certificate(s) must be stored /// in this property in order for AndroidMessageHandler to configure the request to accept the server certificate. - /// AndroidMessageHandler uses a custom and to configure the connection. + /// AndroidMessageHandler uses a custom and to configure the connection. /// If, however, the application requires finer control over the SSL configuration (e.g. it implements its own TrustManager) then - /// it should leave this property empty and instead derive a custom class from AndroidMessageHandler and override, as needed, the + /// it should leave this property empty and instead derive a custom class from AndroidMessageHandler and override, as needed, the /// , and methods /// instead /// @@ -264,6 +264,16 @@ public bool RequestNeedsAuthorization { /// public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromHours (24); +#if !MONOANDROID1_0 + /// + /// A feature switch that determines whether the message handler should attempt to authenticate the user + /// using the NTLM/Negotiate authentication method. Enable the feature by adding + /// true to your project file. + /// + static bool NegotiateAuthenticationIsEnabled => + AppContext.TryGetSwitch ("Xamarin.Android.Net.UseNegotiateAuthentication", out bool isEnabled) && isEnabled; +#endif + /// /// /// Specifies the connect timeout @@ -331,12 +341,38 @@ string EncodeUrl (Uri url) /// Task in which the request is executed /// Request provided by /// Cancellation token. - protected override async Task SendAsync (HttpRequestMessage request, CancellationToken cancellationToken) + protected override Task SendAsync (HttpRequestMessage request, CancellationToken cancellationToken) + { +#if !MONOANDROID1_0 + if (NegotiateAuthenticationIsEnabled) { + return SendWithNegotiateAuthenticationAsync (request, cancellationToken); + } +#endif + + return DoSendAsync (request, cancellationToken); + } + +#if !MONOANDROID1_0 + async Task SendWithNegotiateAuthenticationAsync (HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = await DoSendAsync (request, cancellationToken).ConfigureAwait (false); + + if (RequestNeedsAuthorization && NegotiateAuthenticationHelper.RequestNeedsNegotiateAuthentication (this, request, out var requestedAuth)) { + var authenticatedResponse = await NegotiateAuthenticationHelper.SendWithAuthAsync (this, request, requestedAuth, cancellationToken).ConfigureAwait (false); + if (authenticatedResponse != null) + return authenticatedResponse; + } + + return response; + } +#endif + + internal async Task DoSendAsync (HttpRequestMessage request, CancellationToken cancellationToken) { AssertSelf (); if (request == null) throw new ArgumentNullException (nameof (request)); - + if (!request.RequestUri.IsAbsoluteUri) throw new ArgumentException ("Must represent an absolute URI", "request"); @@ -633,7 +669,7 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H return ret; } - HttpContent GetErrorContent (HttpURLConnection httpConnection, HttpContent fallbackContent) + HttpContent GetErrorContent (HttpURLConnection httpConnection, HttpContent fallbackContent) { var contentStream = httpConnection.ErrorStream; @@ -796,7 +832,7 @@ void CollectAuthInfo (HttpHeaderValueCollection head RequestedAuthentication = authData.AsReadOnly (); } - + AuthenticationScheme GetAuthScheme (string scheme) { if (String.Compare ("basic", scheme, StringComparison.OrdinalIgnoreCase) == 0) @@ -851,7 +887,7 @@ void CopyHeaders (HttpURLConnection httpConnection, HttpResponseMessage response /// /// Configure the before the request is sent. This method is meant to be overriden /// by applications which need to perform some extra configuration steps on the connection. It is called with all - /// the request headers set, pre-authentication performed (if applicable) but before the request body is set + /// the request headers set, pre-authentication performed (if applicable) but before the request body is set /// (e.g. for POST requests). The default implementation in AndroidMessageHandler does nothing. /// /// Request data @@ -859,7 +895,7 @@ void CopyHeaders (HttpURLConnection httpConnection, HttpResponseMessage response protected virtual Task SetupRequest (HttpRequestMessage request, HttpURLConnection conn) { AssertSelf (); - + return Task.CompletedTask; } @@ -905,9 +941,9 @@ internal Task SetupRequestInternal (HttpRequestMessage request, HttpURLConnectio /// /// Create and configure an instance of . The parameter is set to the /// return value of the method, so it might be null if the application overrode the method and provided - /// no key store. It will not be null when the default implementation is used. The application can return null from this + /// no key store. It will not be null when the default implementation is used. The application can return null from this /// method in which case AndroidMessageHandler will create its own instance of the trust manager factory provided that the - /// list contains at least one valid certificate. If there are no valid certificates and this method returns null, no custom + /// list contains at least one valid certificate. If there are no valid certificates and this method returns null, no custom /// trust manager will be created since that would make all the HTTPS requests fail. /// /// The trust manager factory. @@ -930,7 +966,7 @@ void AppendEncoding (string encoding, ref List ? list) return; list.Add (encoding); } - + async Task SetupRequestInternal (HttpRequestMessage request, URLConnection conn) { if (conn == null) @@ -951,7 +987,7 @@ void AppendEncoding (string encoding, ref List ? list) if (request.Content != null) AddHeaders (httpConnection, request.Content.Headers); AddHeaders (httpConnection, request.Headers); - + List ? accept_encoding = null; decompress_here = false; @@ -959,7 +995,7 @@ void AppendEncoding (string encoding, ref List ? list) AppendEncoding (GZIP_ENCODING, ref accept_encoding); decompress_here = true; } - + if ((AutomaticDecompression & DecompressionMethods.Deflate) != 0) { AppendEncoding (DEFLATE_ENCODING, ref accept_encoding); decompress_here = true; @@ -978,7 +1014,7 @@ void AppendEncoding (string encoding, ref List ? list) if (!String.IsNullOrEmpty (cookieHeaderValue)) httpConnection.SetRequestProperty ("Cookie", cookieHeaderValue); } - + HandlePreAuthentication (httpConnection); await SetupRequest (request, httpConnection).ConfigureAwait (continueOnCapturedContext: false);; SetupRequestBody (httpConnection, request); @@ -1035,7 +1071,7 @@ void SetupSSL (HttpsURLConnection? httpsConnection, HttpRequestMessage requestMe // there is no point in changing the behavior of the default SSL socket factory if (!gotCerts && _callbackTrustManagerHelper == null) return; - + tmf = TrustManagerFactory.GetInstance (TrustManagerFactory.DefaultAlgorithm); tmf?.Init (gotCerts ? keyStore : null); // only use the custom key store if the user defined any trusted certs } @@ -1068,7 +1104,7 @@ void SetupSSL (HttpsURLConnection? httpsConnection, HttpRequestMessage requestMe return keyStore; } } - + void HandlePreAuthentication (HttpURLConnection httpConnection) { var data = PreAuthenticationData; @@ -1114,7 +1150,7 @@ void AddHeaders (HttpURLConnection conn, HttpHeaders headers) conn.SetRequestProperty (header.Key, header.Value != null ? String.Join (GetHeaderSeparator (header.Key), header.Value) : String.Empty); } } - + void SetupRequestBody (HttpURLConnection httpConnection, HttpRequestMessage request) { if (request.Content == null) { diff --git a/src/Mono.Android/Xamarin.Android.Net/NegotiateAuthenticationHelper.cs b/src/Mono.Android/Xamarin.Android.Net/NegotiateAuthenticationHelper.cs new file mode 100644 index 00000000000..de025c7b4fd --- /dev/null +++ b/src/Mono.Android/Xamarin.Android.Net/NegotiateAuthenticationHelper.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Security; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Net +{ + // This code is heavily inspired by System.Net.Http.AuthenticationHelper + internal static class NegotiateAuthenticationHelper + { + const int MaxRequests = 10; + + internal class RequestedNegotiateAuthenticationData + { + public string AuthType { get; init; } + public bool IsProxyAuth { get; init; } + public NetworkCredential Credential { get; init; } + } + + internal static bool RequestNeedsNegotiateAuthentication ( + AndroidMessageHandler handler, + HttpRequestMessage request, + [NotNullWhen (true)] out RequestedNegotiateAuthenticationData? requestedAuth) + { + requestedAuth = null; + + IEnumerable authenticationData = handler.RequestedAuthentication ?? Array.Empty (); + foreach (var auth in authenticationData) { + if (TryGetSupportedAuthType (auth.Challenge, out var authType)) { + var credentials = auth.UseProxyAuthentication ? handler.Proxy?.Credentials : handler.Credentials; + var correspondingCredential = credentials?.GetCredential (request.RequestUri, authType); + + if (correspondingCredential != null) { + requestedAuth = new RequestedNegotiateAuthenticationData { + IsProxyAuth = auth.UseProxyAuthentication, + AuthType = authType, + Credential = correspondingCredential + }; + + return true; + } + } + } + + return false; + } + + internal static async Task SendWithAuthAsync ( + AndroidMessageHandler handler, + HttpRequestMessage request, + RequestedNegotiateAuthenticationData requestedAuth, + CancellationToken cancellationToken) + { + using var authContext = new NegotiateAuthentication ( + new NegotiateAuthenticationClientOptions { + Package = requestedAuth.AuthType, + Credential = requestedAuth.Credential, + TargetName = await GetTargetName (handler, request, requestedAuth.IsProxyAuth, cancellationToken).ConfigureAwait (false), + RequiredProtectionLevel = requestedAuth.IsProxyAuth ? ProtectionLevel.None : ProtectionLevel.Sign, + } + ); + + // we need to make sure that the handler doesn't override the authorization header + // with the user defined pre-authentication data + var originalPreAuthenticate = handler.PreAuthenticate; + handler.PreAuthenticate = false; + + try { + return await DoSendWithAuthAsync (handler, request, authContext, requestedAuth, cancellationToken); + } finally { + handler.PreAuthenticate = originalPreAuthenticate; + } + } + + static bool TryGetSupportedAuthType (string challenge, out string authType) + { + var spaceIndex = challenge.IndexOf (' '); + authType = spaceIndex == -1 ? challenge : challenge.Substring (0, spaceIndex); + + return authType.Equals ("NTLM", StringComparison.OrdinalIgnoreCase) || + authType.Equals ("Negotiate", StringComparison.OrdinalIgnoreCase); + } + + static async Task DoSendWithAuthAsync ( + AndroidMessageHandler handler, + HttpRequestMessage request, + NegotiateAuthentication authContext, + RequestedNegotiateAuthenticationData requestedAuth, + CancellationToken cancellationToken) + { + HttpResponseMessage? response = null; + + int requestCounter = 0; + string? challengeData = null; + + while (requestCounter++ < MaxRequests) { + var challengeResponse = authContext.GetOutgoingBlob (challengeData, out var statusCode); + + if (challengeResponse is null || statusCode > NegotiateAuthenticationStatusCode.ContinueNeeded) { + // Response indicated denial even after login, so stop processing and return current response. + break; + } + + if (response is not null) { + // We need to drain the content otherwise the next request + // won't reuse the same TCP socket and persistent auth won't work. + await response.Content.LoadIntoBufferAsync ().ConfigureAwait (false); + } + + SetAuthorizationHeader (request, requestedAuth, challengeResponse); + response = await handler.DoSendAsync (request, cancellationToken).ConfigureAwait (false); + + if (authContext.IsAuthenticated || !TryGetChallenge (response, requestedAuth, out challengeData)) { + break; + } + + if (!IsAuthenticationChallenge (response, requestedAuth)) + { + // Tail response for Negotiate on successful authentication. Validate it before we proceed. + authContext.GetOutgoingBlob(challengeData, out statusCode); + if (statusCode > NegotiateAuthenticationStatusCode.ContinueNeeded) + { + throw new HttpRequestException($"Authentication validation failed with error - {statusCode}.", null, HttpStatusCode.Unauthorized); + } + break; + } + } + + return response; + } + + static async Task GetTargetName ( + AndroidMessageHandler handler, + HttpRequestMessage request, + bool isProxyAuth, + CancellationToken cancellationToken) + { + var hostName = await GetHostName (handler, request, isProxyAuth, cancellationToken); + return $"HTTP/{hostName}"; + } + + static async Task GetHostName ( + AndroidMessageHandler handler, + HttpRequestMessage request, + bool isProxyAuth, + CancellationToken cancellationToken) + { + // Calculate SPN (Service Principal Name) using the host name of the request. + // Use the request's 'Host' header if available. Otherwise, use the request uri. + // Ignore the 'Host' header if this is proxy authentication since we need to use + // the host name of the proxy itself for SPN calculation. + if (!isProxyAuth && request.Headers.Host != null) { + // Use the host name without any normalization. + return request.Headers.Host; + } + + var requestUri = request.RequestUri!; + var authUri = isProxyAuth ? handler.Proxy?.GetProxy (requestUri) ?? requestUri : requestUri; + + // Need to use FQDN normalized host so that CNAME's are traversed. + // Use DNS to do the forward lookup to an A (host) record. + // But skip DNS lookup on IP literals. Otherwise, we would end up + // doing an unintended reverse DNS lookup. + if (authUri.HostNameType == UriHostNameType.IPv6 || authUri.HostNameType == UriHostNameType.IPv4) { + return authUri.IdnHost; + } else { + IPHostEntry result = await Dns.GetHostEntryAsync (authUri.IdnHost, cancellationToken).ConfigureAwait (false); + return result.HostName; + } + } + + static void SetAuthorizationHeader (HttpRequestMessage request, RequestedNegotiateAuthenticationData requestedAuth, string challengeResponse) + { + var headerValue = new AuthenticationHeaderValue (requestedAuth.AuthType, challengeResponse); + if (requestedAuth.IsProxyAuth) { + request.Headers.ProxyAuthorization = headerValue; + } else { + request.Headers.Authorization = headerValue; + } + } + + static bool TryGetChallenge (HttpResponseMessage? response, RequestedNegotiateAuthenticationData requestedAuth, [NotNullWhen (true)] out string? challengeData) + { + challengeData = null; + + var responseHeaderValues = requestedAuth.IsProxyAuth ? response?.Headers.ProxyAuthenticate : response?.Headers.WwwAuthenticate; + if (responseHeaderValues is not null) { + foreach (var headerValue in responseHeaderValues) { + if (headerValue.Scheme == requestedAuth.AuthType) { + challengeData = headerValue.Parameter; + break; + } + } + } + + return !string.IsNullOrEmpty (challengeData); + } + + static bool IsAuthenticationChallenge (HttpResponseMessage response, RequestedNegotiateAuthenticationData requestedAuth) + => requestedAuth.IsProxyAuth + ? response.StatusCode == HttpStatusCode.ProxyAuthenticationRequired + : response.StatusCode == HttpStatusCode.Unauthorized; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets index 3f8f516b880..55284ce2a3a 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets @@ -104,6 +104,7 @@ false + false True diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.RuntimeConfig.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.RuntimeConfig.targets index 1f49b68de85..e7f1364f766 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.RuntimeConfig.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.RuntimeConfig.targets @@ -30,6 +30,14 @@ See: https://github.com/dotnet/runtime/blob/b13715b6984889a709ba29ea8a1961db469f <_RuntimeConfigReservedProperties Include="APP_CONTEXT_BASE_DIRECTORY"/> + + + + + + diff --git a/tests/Mono.Android-Tests/Runtime-Microsoft.Android.Sdk/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Runtime-Microsoft.Android.Sdk/Mono.Android.NET-Tests.csproj index 22d9dce4925..984363d65ee 100644 --- a/tests/Mono.Android-Tests/Runtime-Microsoft.Android.Sdk/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Runtime-Microsoft.Android.Sdk/Mono.Android.NET-Tests.csproj @@ -19,6 +19,7 @@ <_MonoAndroidTestPackage>Mono.Android.NET_Tests -$(TestsFlavor)NET6 IL2037 + true