From 9682abdded40adb46f190748a2b1806b943dc7cd Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sat, 24 Dec 2016 10:54:18 -0500 Subject: [PATCH] HttpService abstraction with CustomAndroidClientHandler to handle xamarin android bug with error response body --- src/Android/Android.csproj | 3 + src/Android/CustomAndroidClientHandler.cs | 774 ++++++++++++++++++ src/Android/MainApplication.cs | 1 + src/Android/Services/HttpService.cs | 12 + src/App/Abstractions/Services/IHttpService.cs | 7 + src/App/App.csproj | 1 + src/App/Repositories/AccountsApiRepository.cs | 10 +- src/App/Repositories/ApiRepository.cs | 16 +- src/App/Repositories/AuthApiRepository.cs | 12 +- src/App/Repositories/BaseApiRepository.cs | 5 +- src/App/Repositories/CipherApiRepository.cs | 12 +- src/App/Repositories/DeviceApiRepository.cs | 10 +- src/App/Repositories/FolderApiRepository.cs | 8 +- src/App/Repositories/SiteApiRepository.cs | 6 +- src/App/Utilities/ApiHttpClient.cs | 11 + src/iOS.Core/Services/HttpService.cs | 11 + src/iOS.Core/iOS.Core.csproj | 1 + src/iOS.Extension/LoadingViewController.cs | 1 + src/iOS/AppDelegate.cs | 1 + 19 files changed, 871 insertions(+), 31 deletions(-) create mode 100644 src/Android/CustomAndroidClientHandler.cs create mode 100644 src/Android/Services/HttpService.cs create mode 100644 src/App/Abstractions/Services/IHttpService.cs create mode 100644 src/iOS.Core/Services/HttpService.cs diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index fd92f494e..1ae8ecabb 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -215,6 +215,7 @@ + ..\..\packages\Validation.2.3.7\lib\dotnet\Validation.dll True @@ -303,6 +304,7 @@ + @@ -311,6 +313,7 @@ + diff --git a/src/Android/CustomAndroidClientHandler.cs b/src/Android/CustomAndroidClientHandler.cs new file mode 100644 index 000000000..654d85bfa --- /dev/null +++ b/src/Android/CustomAndroidClientHandler.cs @@ -0,0 +1,774 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Android.Runtime; +using Java.IO; +using Java.Net; +using Java.Security; +using Java.Security.Cert; +using Javax.Net.Ssl; + +namespace Xamarin.Android.Net +{ + /// + /// A custom implementation of which internally uses + /// (or its HTTPS incarnation) to send HTTP requests. + /// + /// + /// Instance of this class is used to configure instance + /// in the following way: + /// + /// + /// var handler = new AndroidClientHandler { + /// UseCookies = true, + /// AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, + /// }; + /// + /// var httpClient = new HttpClient (handler); + /// var response = httpClient.GetAsync ("http://example.com")?.Result as AndroidHttpResponseMessage; + /// + /// + /// The class supports pre-authentication of requests albeit in a slightly "manual" way. Namely, whenever a request to a server requiring authentication + /// is made and no authentication credentials are provided in the property (which is usually the case on the first + /// request), the property will return true and the property will + /// contain all the authentication information gathered from the server. The application must then fill in the blanks (i.e. the credentials) and re-send + /// the request configured to perform pre-authentication. The reason for this manual process is that the underlying Java HTTP client API supports only a + /// single, VM-wide, authentication handler which cannot be configured to handle credentials for several requests. AndroidClientHandler, therefore, implements + /// the authentication in managed .NET code. Message handler supports both Basic and Digest authentication. If an authentication scheme that's not supported + /// by AndroidClientHandler is requested by the server, the application can provide its own authentication module (, + /// ) to handle the protocol authorization. + /// AndroidClientHandler also supports requests to servers with "invalid" (e.g. self-signed) SSL certificates. Since this process is a bit convoluted using + /// the Java APIs, AndroidClientHandler defines two ways to handle the situation. First, easier, is to store the necessary certificates (either CA or server certificates) + /// in the collection or, after deriving a custom class from AndroidClientHandler, by overriding one or more methods provided for this purpose + /// (, and ). The former method should be sufficient + /// for most use cases, the latter allows the application to provide fully customized key store, trust manager and key manager, if needed. Note that the instance of + /// AndroidClientHandler configured to accept an "invalid" certificate from the particular server will most likely fail to validate certificates from other servers (even + /// if they use a certificate with a fully validated trust chain) unless you store the CA certificates from your Android system in along with + /// the self-signed certificate(s). + /// + public class CustomAndroidClientHandler : HttpClientHandler + { + sealed class RequestRedirectionState + { + public Uri NewUrl; + public int RedirectCounter; + public HttpMethod Method; + } + + internal const string LOG_APP = "monodroid-net"; + + const string GZIP_ENCODING = "gzip"; + const string DEFLATE_ENCODING = "deflate"; + const string IDENTITY_ENCODING = "identity"; + + static readonly HashSet known_content_headers = new HashSet(StringComparer.OrdinalIgnoreCase) { + "Allow", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-MD5", + "Content-Range", + "Content-Type", + "Expires", + "Last-Modified" + }; + + static readonly List authModules = new List { + new AuthModuleBasic (), + // COMMENTED OUT: Kyle + //new AuthModuleDigest () + }; + + bool disposed; + + // Now all hail Java developers! Get this... HttpURLClient defaults to accepting AND + // uncompressing the gzip content encoding UNLESS you set the Accept-Encoding header to ANY + // value. So if we set it to 'gzip' below we WILL get gzipped stream but HttpURLClient will NOT + // uncompress it any longer, doh. And they don't support 'deflate' so we need to handle it ourselves. + bool decompress_here; + + /// + /// + /// Gets or sets the pre authentication data for the request. This property must be set by the application + /// before the request is made. Generally the value can be taken from + /// after the initial request, without any authentication data, receives the authorization request from the + /// server. The application must then store credentials in instance of and + /// assign the instance to this propery before retrying the request. + /// + /// + /// The property is never set by AndroidClientHandler. + /// + /// + /// 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 + /// need to check the status code and and (re)configure AndroidClientHandler instance accordingly by providing + /// both the credentials and the authentication scheme by setting the + /// property. If AndroidClientHandler is not able to detect the kind of authentication scheme it will store an + /// instance of with its property + /// set to AuthenticationScheme.Unsupported and the application will be responsible for providing an + /// instance of which handles this kind of authorization scheme + /// ( + /// + public IList RequestedAuthentication { get; private set; } + + /// + /// Server authentication response indicates that the request to authorize comes from a proxy if this property is true. + /// All the instances of stored in the property will + /// have their preset to the same value as this property. + /// + public bool ProxyAuthenticationRequested { get; private set; } + + /// + /// If true then the server requested authorization and the application must use information + /// found in to set the value of + /// + public bool RequestNeedsAuthorization + { + get { return RequestedAuthentication?.Count > 0; } + } + + /// + /// + /// 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 + /// 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 AndroidClientHandler to configure the request to accept the server certificate. + /// AndroidClientHandler 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 AndroidClientHandler and override, as needed, the + /// , and methods + /// instead + /// + /// The trusted certs. + public IList TrustedCerts { get; set; } + + protected override void Dispose(bool disposing) + { + disposed = true; + + base.Dispose(disposing); + } + + protected void AssertSelf() + { + if(!disposed) + return; + throw new ObjectDisposedException(nameof(AndroidClientHandler)); + } + + string EncodeUrl(Uri url) + { + if(url == null) + return String.Empty; + + if(String.IsNullOrEmpty(url.Query)) + return Uri.EscapeUriString(url.ToString()); + + // UriBuilder takes care of encoding everything properly + var bldr = new UriBuilder(url); + if(url.IsDefaultPort) + bldr.Port = -1; // Avoids adding :80 or :443 to the host name in the result + + // bldr.Uri.ToString () would ruin the good job UriBuilder did + return bldr.ToString(); + } + + /// + /// Creates, configures and processes an asynchronous request to the indicated resource. + /// + /// Task in which the request is executed + /// Request provided by + /// Cancellation token. + protected override async Task SendAsync(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"); + + var redirectState = new RequestRedirectionState + { + NewUrl = request.RequestUri, + RedirectCounter = 0, + Method = request.Method + }; + while(true) + { + URL java_url = new URL(EncodeUrl(redirectState.NewUrl)); + URLConnection java_connection = java_url.OpenConnection(); + HttpURLConnection httpConnection = await SetupRequestInternal(request, java_connection); + HttpResponseMessage response = await ProcessRequest(request, java_url, httpConnection, cancellationToken, redirectState); + if(response != null) + return response; + + if(redirectState.NewUrl == null) + throw new InvalidOperationException("Request redirected but no new URI specified"); + request.Method = redirectState.Method; + } + } + + Task ProcessRequest(HttpRequestMessage request, URL javaUrl, HttpURLConnection httpConnection, CancellationToken cancellationToken, RequestRedirectionState redirectState) + { + cancellationToken.ThrowIfCancellationRequested(); + httpConnection.InstanceFollowRedirects = false; // We handle it ourselves + RequestedAuthentication = null; + ProxyAuthenticationRequested = false; + + return DoProcessRequest(request, javaUrl, httpConnection, cancellationToken, redirectState); + } + + async Task DoProcessRequest(HttpRequestMessage request, URL javaUrl, HttpURLConnection httpConnection, CancellationToken cancellationToken, RequestRedirectionState redirectState) + { + if(cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + try + { + await Task.WhenAny( + httpConnection.ConnectAsync(), + Task.Run(() => { cancellationToken.WaitHandle.WaitOne(); })) + .ConfigureAwait(false); + } + catch(Java.Net.ConnectException ex) + { + // Wrap it nicely in a "standard" exception so that it's compatible with HttpClientHandler + throw new WebException(ex.Message, ex, WebExceptionStatus.ConnectFailure, null); + } + + if(cancellationToken.IsCancellationRequested) + { + httpConnection.Disconnect(); + cancellationToken.ThrowIfCancellationRequested(); + } + cancellationToken.Register(httpConnection.Disconnect); + + if(httpConnection.DoOutput) + { + using(var stream = await request.Content.ReadAsStreamAsync()) + { + await stream.CopyToAsync(httpConnection.OutputStream, 4096, cancellationToken) + .ConfigureAwait(false); + } + } + + if(cancellationToken.IsCancellationRequested) + { + httpConnection.Disconnect(); + cancellationToken.ThrowIfCancellationRequested(); + } + + var statusCode = await Task.Run(() => (HttpStatusCode)httpConnection.ResponseCode).ConfigureAwait(false); + var connectionUri = new Uri(httpConnection.URL.ToString()); + + // If the request was redirected we need to put the new URL in the request + request.RequestUri = connectionUri; + var ret = new AndroidHttpResponseMessage(javaUrl, httpConnection) + { + RequestMessage = request, + ReasonPhrase = httpConnection.ResponseMessage, + StatusCode = statusCode, + }; + + bool disposeRet; + if(HandleRedirect(statusCode, httpConnection, redirectState, out disposeRet)) + { + if(disposeRet) + { + ret.Dispose(); + ret = null; + } + return ret; + } + + switch(statusCode) + { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.ProxyAuthenticationRequired: + // We don't resend the request since that would require new set of credentials if the + // ones provided in Credentials are invalid (or null) and that, in turn, may require asking the + // user which is not something that should be taken care of by us and in this + // context. The application should be responsible for this. + // HttpClientHandler throws an exception in this instance, but I think it's not a good + // idea. We'll return the response message with all the information required by the + // application to fill in the blanks and provide the requested credentials instead. + // + // We return the body of the response too, but the Java client will throw + // a FileNotFound exception if we attempt to access the input stream. + // Instead we try to read the error stream and return an default message if the error stream isn't readable. + ret.Content = GetErrorContent(httpConnection, new StringContent("Unauthorized", Encoding.ASCII)); + CopyHeaders(httpConnection, ret); + + if(ret.Headers.WwwAuthenticate != null) + { + ProxyAuthenticationRequested = false; + CollectAuthInfo(ret.Headers.WwwAuthenticate); + } + else if(ret.Headers.ProxyAuthenticate != null) + { + ProxyAuthenticationRequested = true; + CollectAuthInfo(ret.Headers.ProxyAuthenticate); + } + + // COMMENTED OUT: Kyle + //ret.RequestedAuthentication = RequestedAuthentication; + return ret; + } + + if(!IsErrorStatusCode(statusCode)) + { + ret.Content = GetContent(httpConnection, httpConnection.InputStream); + } + else + { + // For 400 >= response code <= 599 the Java client throws the FileNotFound exception when attempting to read from the input stream. + // Instead we try to read the error stream and return an empty string if the error stream isn't readable. + ret.Content = GetErrorContent(httpConnection, new StringContent(String.Empty, Encoding.ASCII)); + } + + CopyHeaders(httpConnection, ret); + + IEnumerable cookieHeaderValue; + if(!UseCookies || CookieContainer == null || !ret.Headers.TryGetValues("Set-Cookie", out cookieHeaderValue) || cookieHeaderValue == null) + { + return ret; + } + + try + { + CookieContainer.SetCookies(connectionUri, String.Join(",", cookieHeaderValue)); + } + catch(Exception ex) + { + // We don't want to terminate the response because of a bad cookie, hence just reporting + // the issue. We might consider adding a virtual method to let the user handle the + // issue, but not sure if it's really needed. Set-Cookie header will be part of the + // header collection so the user can always examine it if they spot an error. + } + + return ret; + } + + HttpContent GetErrorContent(HttpURLConnection httpConnection, HttpContent fallbackContent) + { + var contentStream = httpConnection.ErrorStream; + + if(contentStream != null) + { + return GetContent(httpConnection, contentStream); + } + + return fallbackContent; + } + + HttpContent GetContent(URLConnection httpConnection, Stream contentStream) + { + Stream inputStream = new BufferedStream(contentStream); + if(decompress_here) + { + string[] encodings = httpConnection.ContentEncoding?.Split(','); + if(encodings != null) + { + if(encodings.Contains(GZIP_ENCODING, StringComparer.OrdinalIgnoreCase)) + inputStream = new GZipStream(inputStream, CompressionMode.Decompress); + else if(encodings.Contains(DEFLATE_ENCODING, StringComparer.OrdinalIgnoreCase)) + inputStream = new DeflateStream(inputStream, CompressionMode.Decompress); + } + } + return new StreamContent(inputStream); + } + + bool HandleRedirect(HttpStatusCode redirectCode, HttpURLConnection httpConnection, RequestRedirectionState redirectState, out bool disposeRet) + { + if(!AllowAutoRedirect) + { + disposeRet = false; + return true; // We shouldn't follow and there's no data to fetch, just return + } + disposeRet = true; + + redirectState.NewUrl = null; + switch(redirectCode) + { + case HttpStatusCode.MultipleChoices: // 300 + break; + + case HttpStatusCode.Moved: // 301 + case HttpStatusCode.Redirect: // 302 + case HttpStatusCode.SeeOther: // 303 + redirectState.Method = HttpMethod.Get; + break; + + case HttpStatusCode.TemporaryRedirect: // 307 + break; + + default: + if((int)redirectCode >= 300 && (int)redirectCode < 400) + throw new InvalidOperationException($"HTTP Redirection status code {redirectCode} ({(int)redirectCode}) not supported"); + return false; + } + + IDictionary> headers = httpConnection.HeaderFields; + IList locationHeader; + if(!headers.TryGetValue("Location", out locationHeader) || locationHeader == null || locationHeader.Count == 0) + throw new InvalidOperationException($"HTTP connection redirected with code {redirectCode} ({(int)redirectCode}) but no Location header found in response"); + + + redirectState.RedirectCounter++; + if(redirectState.RedirectCounter >= MaxAutomaticRedirections) + throw new WebException($"Maximum automatic redirections exceeded (allowed {MaxAutomaticRedirections}, redirected {redirectState.RedirectCounter} times)"); + + Uri location = new Uri(locationHeader[0], UriKind.Absolute); + redirectState.NewUrl = location; + + return true; + } + + bool IsErrorStatusCode(HttpStatusCode statusCode) + { + return (int)statusCode >= 400 && (int)statusCode <= 599; + } + + void CollectAuthInfo(HttpHeaderValueCollection headers) + { + var authData = new List(headers.Count); + + foreach(AuthenticationHeaderValue ahv in headers) + { + var data = new AuthenticationData + { + Scheme = GetAuthScheme(ahv.Scheme), + // COMMENTED OUT: Kyle + //Challenge = $"{ahv.Scheme} {ahv.Parameter}", + UseProxyAuthentication = ProxyAuthenticationRequested + }; + authData.Add(data); + } + + RequestedAuthentication = authData.AsReadOnly(); + } + + AuthenticationScheme GetAuthScheme(string scheme) + { + if(String.Compare("basic", scheme, StringComparison.OrdinalIgnoreCase) == 0) + return AuthenticationScheme.Basic; + if(String.Compare("digest", scheme, StringComparison.OrdinalIgnoreCase) == 0) + return AuthenticationScheme.Digest; + + return AuthenticationScheme.Unsupported; + } + + void CopyHeaders(HttpURLConnection httpConnection, HttpResponseMessage response) + { + IDictionary> headers = httpConnection.HeaderFields; + foreach(string key in headers.Keys) + { + if(key == null) // First header entry has null key, it corresponds to the response message + continue; + + HttpHeaders item_headers; + string kind; + if(known_content_headers.Contains(key)) + { + kind = "content"; + item_headers = response.Content.Headers; + } + else + { + kind = "response"; + item_headers = response.Headers; + } + item_headers.TryAddWithoutValidation(key, headers[key]); + } + } + + /// + /// 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 + /// (e.g. for POST requests). The default implementation in AndroidClientHandler does nothing. + /// + /// Request data + /// Pre-configured connection instance + protected virtual Task SetupRequest(HttpRequestMessage request, HttpURLConnection conn) + { + return Task.Factory.StartNew(AssertSelf); + } + + /// + /// Configures the key store. The parameter is set to instance of + /// created using the type and with populated with certificates provided in the + /// property. AndroidClientHandler implementation simply returns the instance passed in the parameter + /// + /// The key store. + /// Key store to configure. + protected virtual KeyStore ConfigureKeyStore(KeyStore keyStore) + { + AssertSelf(); + + return keyStore; + } + + /// + /// 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 here since + /// KeyManagerFactory is not required for the custom SSL configuration, but it might be used by the application to implement a more advanced + /// mechanism of key management. + /// + /// The key manager factory or null. + /// Key store. + protected virtual KeyManagerFactory ConfigureKeyManagerFactory(KeyStore keyStore) + { + AssertSelf(); + + return null; + } + + /// + /// 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 + /// method in which case AndroidClientHandler 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 + /// trust manager will be created since that would make all the HTTPS requests fail. + /// + /// The trust manager factory. + /// Key store. + protected virtual TrustManagerFactory ConfigureTrustManagerFactory(KeyStore keyStore) + { + AssertSelf(); + + return null; + } + + void AppendEncoding(string encoding, ref List list) + { + if(list == null) + list = new List(); + if(list.Contains(encoding)) + return; + list.Add(encoding); + } + + async Task SetupRequestInternal(HttpRequestMessage request, URLConnection conn) + { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + var httpConnection = conn.JavaCast(); + if(httpConnection == null) + throw new InvalidOperationException($"Unsupported URL scheme {conn.URL.Protocol}"); + + httpConnection.RequestMethod = request.Method.ToString(); + + // SSL context must be set up as soon as possible, before adding any content or + // headers. Otherwise Java won't use the socket factory + SetupSSL(httpConnection as HttpsURLConnection); + if(request.Content != null) + AddHeaders(httpConnection, request.Content.Headers); + AddHeaders(httpConnection, request.Headers); + + List accept_encoding = null; + + decompress_here = false; + if((AutomaticDecompression & DecompressionMethods.GZip) != 0) + { + AppendEncoding(GZIP_ENCODING, ref accept_encoding); + decompress_here = true; + } + + if((AutomaticDecompression & DecompressionMethods.Deflate) != 0) + { + AppendEncoding(DEFLATE_ENCODING, ref accept_encoding); + decompress_here = true; + } + + if(AutomaticDecompression == DecompressionMethods.None) + { + accept_encoding?.Clear(); + AppendEncoding(IDENTITY_ENCODING, ref accept_encoding); // Turns off compression for the Java client + } + + if(accept_encoding?.Count > 0) + httpConnection.SetRequestProperty("Accept-Encoding", String.Join(",", accept_encoding)); + + if(UseCookies && CookieContainer != null) + { + string cookieHeaderValue = CookieContainer.GetCookieHeader(request.RequestUri); + if(!String.IsNullOrEmpty(cookieHeaderValue)) + httpConnection.SetRequestProperty("Cookie", cookieHeaderValue); + } + + HandlePreAuthentication(httpConnection); + await SetupRequest(request, httpConnection); + SetupRequestBody(httpConnection, request); + + return httpConnection; + } + + void SetupSSL(HttpsURLConnection httpsConnection) + { + if(httpsConnection == null) + return; + + KeyStore keyStore = KeyStore.GetInstance(KeyStore.DefaultType); + keyStore.Load(null, null); + bool gotCerts = TrustedCerts?.Count > 0; + if(gotCerts) + { + for(int i = 0; i < TrustedCerts.Count; i++) + { + Certificate cert = TrustedCerts[i]; + if(cert == null) + continue; + keyStore.SetCertificateEntry($"ca{i}", cert); + } + } + keyStore = ConfigureKeyStore(keyStore); + KeyManagerFactory kmf = ConfigureKeyManagerFactory(keyStore); + TrustManagerFactory tmf = ConfigureTrustManagerFactory(keyStore); + + if(tmf == null) + { + // If there are no certs and no trust manager factory, we can't use a custom manager + // because it will cause all the HTTPS requests to fail because of unverified trust + // chain + if(!gotCerts) + return; + + tmf = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm); + tmf.Init(keyStore); + } + + SSLContext context = SSLContext.GetInstance("TLS"); + context.Init(kmf?.GetKeyManagers(), tmf.GetTrustManagers(), null); + httpsConnection.SSLSocketFactory = context.SocketFactory; + } + + void HandlePreAuthentication(HttpURLConnection httpConnection) + { + AuthenticationData data = PreAuthenticationData; + if(!PreAuthenticate || data == null) + return; + + ICredentials creds = data.UseProxyAuthentication ? Proxy?.Credentials : Credentials; + if(creds == null) + { + return; + } + + IAndroidAuthenticationModule auth = data.Scheme == AuthenticationScheme.Unsupported ? data.AuthModule : authModules.Find(m => m?.Scheme == data.Scheme); + if(auth == null) + { + return; + } + + Authorization authorization = auth.Authenticate(data.Challenge, httpConnection, creds); + if(authorization == null) + { + return; + } + + httpConnection.SetRequestProperty(data.UseProxyAuthentication ? "Proxy-Authorization" : "Authorization", authorization.Message); + } + + void AddHeaders(HttpURLConnection conn, HttpHeaders headers) + { + if(headers == null) + return; + + foreach(KeyValuePair> header in headers) + { + conn.SetRequestProperty(header.Key, header.Value != null ? String.Join(",", header.Value) : String.Empty); + } + } + + void SetupRequestBody(HttpURLConnection httpConnection, HttpRequestMessage request) + { + if(request.Content == null) + { + // Pilfered from System.Net.Http.HttpClientHandler:SendAync + if(HttpMethod.Post.Equals(request.Method) || HttpMethod.Put.Equals(request.Method) || HttpMethod.Delete.Equals(request.Method)) + { + // Explicitly set this to make sure we're sending a "Content-Length: 0" header. + // This fixes the issue that's been reported on the forums: + // http://forums.xamarin.com/discussion/17770/length-required-error-in-http-post-since-latest-release + httpConnection.SetRequestProperty("Content-Length", "0"); + } + return; + } + + httpConnection.DoOutput = true; + long? contentLength = request.Content.Headers.ContentLength; + if(contentLength != null) + httpConnection.SetFixedLengthStreamingMode((int)contentLength); + else + httpConnection.SetChunkedStreamingMode(0); + } + } + + sealed class AuthModuleBasic : IAndroidAuthenticationModule + { + public AuthenticationScheme Scheme { get; } = AuthenticationScheme.Basic; + public string AuthenticationType { get; } = "Basic"; + public bool CanPreAuthenticate { get; } = true; + + public Authorization Authenticate(string challenge, HttpURLConnection request, ICredentials credentials) + { + string header = challenge?.Trim(); + if(credentials == null || String.IsNullOrEmpty(header)) + return null; + + if(header.IndexOf("basic", StringComparison.OrdinalIgnoreCase) == -1) + return null; + + return InternalAuthenticate(request, credentials); + } + + public Authorization PreAuthenticate(HttpURLConnection request, ICredentials credentials) + { + return InternalAuthenticate(request, credentials); + } + + Authorization InternalAuthenticate(HttpURLConnection request, ICredentials credentials) + { + if(request == null || credentials == null) + return null; + + NetworkCredential cred = credentials.GetCredential(new Uri(request.URL.ToString()), AuthenticationType.ToLowerInvariant()); + if(cred == null) + return null; + + if(String.IsNullOrEmpty(cred.UserName)) + return null; + + string domain = cred.Domain?.Trim(); + string response = String.Empty; + + // If domain is set, MS sends "domain\user:password". + if(!String.IsNullOrEmpty(domain)) + response = domain + "\\"; + response += cred.UserName + ":" + cred.Password; + + return new Authorization($"{AuthenticationType} {Convert.ToBase64String(Encoding.ASCII.GetBytes(response))}"); + } + } +} \ No newline at end of file diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index 801fb5e25..7aa789235 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -209,6 +209,7 @@ namespace Bit.Android .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) + .RegisterType(new ContainerControlledLifetimeManager()) // Repositories .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) diff --git a/src/Android/Services/HttpService.cs b/src/Android/Services/HttpService.cs new file mode 100644 index 000000000..8879b70b4 --- /dev/null +++ b/src/Android/Services/HttpService.cs @@ -0,0 +1,12 @@ +using System; +using Xamarin.Android.Net; +using Bit.App; +using Bit.App.Abstractions; + +namespace Bit.Android.Services +{ + public class HttpService : IHttpService + { + public ApiHttpClient Client => new ApiHttpClient(new CustomAndroidClientHandler()); + } +} diff --git a/src/App/Abstractions/Services/IHttpService.cs b/src/App/Abstractions/Services/IHttpService.cs new file mode 100644 index 000000000..85c38f6f9 --- /dev/null +++ b/src/App/Abstractions/Services/IHttpService.cs @@ -0,0 +1,7 @@ +namespace Bit.App.Abstractions +{ + public interface IHttpService + { + ApiHttpClient Client { get; } + } +} diff --git a/src/App/App.csproj b/src/App/App.csproj index 979eb8b0c..672fc443c 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -37,6 +37,7 @@ + diff --git a/src/App/Repositories/AccountsApiRepository.cs b/src/App/Repositories/AccountsApiRepository.cs index 0028817b2..b8536c6c7 100644 --- a/src/App/Repositories/AccountsApiRepository.cs +++ b/src/App/Repositories/AccountsApiRepository.cs @@ -10,8 +10,10 @@ namespace Bit.App.Repositories { public class AccountsApiRepository : BaseApiRepository, IAccountsApiRepository { - public AccountsApiRepository(IConnectivity connectivity) - : base(connectivity) + public AccountsApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } protected override string ApiRoute => "accounts"; @@ -23,7 +25,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(requestObj) { @@ -55,7 +57,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(requestObj) { diff --git a/src/App/Repositories/ApiRepository.cs b/src/App/Repositories/ApiRepository.cs index bfa074545..6e916c354 100644 --- a/src/App/Repositories/ApiRepository.cs +++ b/src/App/Repositories/ApiRepository.cs @@ -15,8 +15,10 @@ namespace Bit.App.Repositories where TRequest : class where TResponse : class { - public ApiRepository(IConnectivity connectivity) - : base(connectivity) + public ApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } public virtual async Task> GetByIdAsync(TId id) @@ -26,7 +28,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { @@ -60,7 +62,7 @@ namespace Bit.App.Repositories return HandledNotConnected>(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { @@ -94,7 +96,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(requestObj) { @@ -128,7 +130,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(requestObj) { @@ -162,7 +164,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { diff --git a/src/App/Repositories/AuthApiRepository.cs b/src/App/Repositories/AuthApiRepository.cs index 709960de9..7e8fca602 100644 --- a/src/App/Repositories/AuthApiRepository.cs +++ b/src/App/Repositories/AuthApiRepository.cs @@ -11,8 +11,10 @@ namespace Bit.App.Repositories { public class AuthApiRepository : BaseApiRepository, IAuthApiRepository { - public AuthApiRepository(IConnectivity connectivity) - : base(connectivity) + public AuthApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } protected override string ApiRoute => "auth"; @@ -24,7 +26,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(requestObj) { @@ -44,7 +46,7 @@ namespace Bit.App.Repositories var responseObj = JsonConvert.DeserializeObject(responseContent); return ApiResult.Success(responseObj, response.StatusCode); } - catch(WebException) + catch(WebException e) { return HandledWebException(); } @@ -58,7 +60,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(requestObj) { diff --git a/src/App/Repositories/BaseApiRepository.cs b/src/App/Repositories/BaseApiRepository.cs index 717094b38..c7e63ee06 100644 --- a/src/App/Repositories/BaseApiRepository.cs +++ b/src/App/Repositories/BaseApiRepository.cs @@ -5,17 +5,20 @@ using System.Threading.Tasks; using Bit.App.Models.Api; using Newtonsoft.Json; using Plugin.Connectivity.Abstractions; +using Bit.App.Abstractions; namespace Bit.App.Repositories { public abstract class BaseApiRepository { - public BaseApiRepository(IConnectivity connectivity) + public BaseApiRepository(IConnectivity connectivity, IHttpService httpService) { Connectivity = connectivity; + HttpService = httpService; } protected IConnectivity Connectivity { get; private set; } + protected IHttpService HttpService { get; private set; } protected abstract string ApiRoute { get; } protected ApiResult HandledNotConnected() diff --git a/src/App/Repositories/CipherApiRepository.cs b/src/App/Repositories/CipherApiRepository.cs index 66fa6f51d..cd4483681 100644 --- a/src/App/Repositories/CipherApiRepository.cs +++ b/src/App/Repositories/CipherApiRepository.cs @@ -12,8 +12,10 @@ namespace Bit.App.Repositories { public class CipherApiRepository : BaseApiRepository, ICipherApiRepository { - public CipherApiRepository(IConnectivity connectivity) - : base(connectivity) + public CipherApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } protected override string ApiRoute => "ciphers"; @@ -25,7 +27,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { @@ -59,7 +61,7 @@ namespace Bit.App.Repositories return HandledNotConnected>(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { @@ -93,7 +95,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { diff --git a/src/App/Repositories/DeviceApiRepository.cs b/src/App/Repositories/DeviceApiRepository.cs index 4272f9453..95c5a7031 100644 --- a/src/App/Repositories/DeviceApiRepository.cs +++ b/src/App/Repositories/DeviceApiRepository.cs @@ -11,8 +11,10 @@ namespace Bit.App.Repositories { public class DeviceApiRepository : ApiRepository, IDeviceApiRepository { - public DeviceApiRepository(IConnectivity connectivity) - : base(connectivity) + public DeviceApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } protected override string ApiRoute => "devices"; @@ -24,7 +26,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage(request) { @@ -56,7 +58,7 @@ namespace Bit.App.Repositories return HandledNotConnected(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage { diff --git a/src/App/Repositories/FolderApiRepository.cs b/src/App/Repositories/FolderApiRepository.cs index 34af4fbd0..2bf528a04 100644 --- a/src/App/Repositories/FolderApiRepository.cs +++ b/src/App/Repositories/FolderApiRepository.cs @@ -12,8 +12,10 @@ namespace Bit.App.Repositories { public class FolderApiRepository : ApiRepository, IFolderApiRepository { - public FolderApiRepository(IConnectivity connectivity) - : base(connectivity) + public FolderApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } protected override string ApiRoute => "folders"; @@ -25,7 +27,7 @@ namespace Bit.App.Repositories return HandledNotConnected>(); } - using(var client = new ApiHttpClient()) + using(var client = HttpService.Client) { var requestMessage = new TokenHttpRequestMessage() { diff --git a/src/App/Repositories/SiteApiRepository.cs b/src/App/Repositories/SiteApiRepository.cs index 735191a0f..f93882e69 100644 --- a/src/App/Repositories/SiteApiRepository.cs +++ b/src/App/Repositories/SiteApiRepository.cs @@ -11,8 +11,10 @@ namespace Bit.App.Repositories { public class SiteApiRepository : ApiRepository, ISiteApiRepository { - public SiteApiRepository(IConnectivity connectivity) - : base(connectivity) + public SiteApiRepository( + IConnectivity connectivity, + IHttpService httpService) + : base(connectivity, httpService) { } protected override string ApiRoute => "sites"; diff --git a/src/App/Utilities/ApiHttpClient.cs b/src/App/Utilities/ApiHttpClient.cs index 3a3706ebf..498cae7d6 100644 --- a/src/App/Utilities/ApiHttpClient.cs +++ b/src/App/Utilities/ApiHttpClient.cs @@ -7,6 +7,17 @@ namespace Bit.App public class ApiHttpClient : HttpClient { public ApiHttpClient() + { + Init(); + } + + public ApiHttpClient(HttpMessageHandler handler) + : base(handler) + { + Init(); + } + + private void Init() { BaseAddress = new Uri("https://api.bitwarden.com"); DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); diff --git a/src/iOS.Core/Services/HttpService.cs b/src/iOS.Core/Services/HttpService.cs new file mode 100644 index 000000000..8d7f5217e --- /dev/null +++ b/src/iOS.Core/Services/HttpService.cs @@ -0,0 +1,11 @@ +using System; +using Bit.App; +using Bit.App.Abstractions; + +namespace Bit.iOS.Core.Services +{ + public class HttpService : IHttpService + { + public ApiHttpClient Client => new ApiHttpClient(); + } +} diff --git a/src/iOS.Core/iOS.Core.csproj b/src/iOS.Core/iOS.Core.csproj index b4a98ff6c..5a57f1355 100644 --- a/src/iOS.Core/iOS.Core.csproj +++ b/src/iOS.Core/iOS.Core.csproj @@ -106,6 +106,7 @@ + diff --git a/src/iOS.Extension/LoadingViewController.cs b/src/iOS.Extension/LoadingViewController.cs index aab8d4457..a9216d09c 100644 --- a/src/iOS.Extension/LoadingViewController.cs +++ b/src/iOS.Extension/LoadingViewController.cs @@ -279,6 +279,7 @@ namespace Bit.iOS.Extension .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) + .RegisterType(new ContainerControlledLifetimeManager()) // Repositories .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) diff --git a/src/iOS/AppDelegate.cs b/src/iOS/AppDelegate.cs index be52eafa1..830a2d0f6 100644 --- a/src/iOS/AppDelegate.cs +++ b/src/iOS/AppDelegate.cs @@ -262,6 +262,7 @@ namespace Bit.iOS .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) .RegisterType(new ContainerControlledLifetimeManager()) + .RegisterType(new ContainerControlledLifetimeManager()) // Repositories // Repositories .RegisterType(new ContainerControlledLifetimeManager())