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 8cc95be552f..8c87b408b59 100644 --- a/src/Mono.Android/Mono.Android.csproj +++ b/src/Mono.Android/Mono.Android.csproj @@ -368,6 +368,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 baa52a204bc..f0a621a1f45 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