I have an ASP.NET application written in VB.NET which accepts a file upload from the user and allows the user to review stuff, and then download a PDF copy of the report page. I am trying to add an API so that this can be done programmatically by a third party. I chose to implement a SOAP web service to make it easy for them AND to not have to refactor my application, since the report file is generated by modifying the HTML returned by the application, and thus I need it to emulate a browser in the sense that it's POSTing a file and then doing a GET on the report page (with a header set to inform the application to cache the report PDF as a byte array, which the WS can then return to the client). The ASMX file can then just be hosted on the existing site.
I also wrote a C# test client to test uploading a file to the web service and getting a report file back from it (and for any other API methods they might need later), so that I can advise my third party on how to configure their end. Everything works fine in Visual Studio running locally, but when I run it on my test server, running IIS 8.5, I get 401 Unauthorized no matter what I have tried thus far. I've poked around Stack Overflow looking for solutions, but nothing has worked for me yet.
Client upload method
(I don't know if either nested property within ws is needed to be messed with)
private void btnUpload_Click(object sender, EventArgs e)
{
byte[] reportBytes;
string fileName = (openFileDialog1.FileName.Split('\\').Last());
DialogResult dialogResult;
//initialize the client, set important settings
using WebServiceSoapClient ws = new WebServiceSoapClient();
ws.ClientCredentials.Windows.AllowedImpersonationLevel = System.Security.Principal.TokenImpersonationLevel.Delegation; //this has to happen before we mess with channels because the vars are readonly in there
ws.InnerChannel.OperationTimeout = new TimeSpan(0, 20, 0); //20 minutes is overkill, but this is for debug. default is 1 minute, not enough time
//FileBytes is a module level variable and must not be null here
Debug.Assert(FileBytes?.Length > 0, "No file has been loaded!");
//retrieve the report; TODO: this could take awhile, so consider making Async later
reportBytes = ws.GetReport(fileName, FileBytes);
//ask the user to pick a place for this file; the name of the file is up to us as client, and we know it's a pdf
if (reportBytes != null)
{
//save the file to the chosen place with a dialog - omitted because this works fine
}
else
{
MessageBox.Show("No content was returned!");
}
}
Relevant portion of client config
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<system.serviceModel>
<bindings>
<basicHttpBinding>
<binding name="[name of my service]" maxBufferSize="2147483647" maxBufferPoolSize="524288" maxReceivedMessageSize="2147483647">
<security mode="Transport"> <!--This needs to be Transport for server, and None for debugging on local machine-->
<transport clientCredentialType="Ntlm" proxyCredentialType="Ntlm" realm="" />
<!--ntlm for internal, None for external-->
<message clientCredentialType="Certificate" algorithmSuite="Default" />
</security>
</binding>
</basicHttpBinding>
</bindings>
<client>
<endpoint address="https://[myserver/myvirtualfolder/myservice].asmx" binding="basicHttpBinding" bindingConfiguration="[myService]Soap" contract="[myService].[MyService]Soap" name="[myService]Soap" />
</client>
</system.serviceModel>
...
</configuration>
Web Service Method
<WebMethod(EnableSession:=True)>
Public Function GetReport(LogFileName As String, LogFileContent As Byte()) As Byte()
'constants for input validation / error handling - omitted for brevity
'artifacts for our upload processing
Dim targetUri As String = Context.Request.Url.OriginalString
Dim authority As String = Context.Request.Url.Authority
Dim formData As MultipartFormDataContent
Dim bytesContent As HttpContent
Dim uploadHandlerRm As HttpResponseMessage, viewReportRm As HttpResponseMessage
Dim pickupCode As String = getPickupCode() 'a cryptographically secure, random 256 bit string
'INFO (see answer from Joshcodes): https://stackoverflow.com/questions/1131425/send-a-file-via-http-post-with-c-sharp?noredirect=1&lq=1
'Better INFO (same answer with more inline comments): https://stackoverflow.com/questions/566462/upload-files-with-httpwebrequest-multipart-form-data
Try
'input validation - omitted for brevity
'set targets/variables
targetUri = targetUri.Substring(0, targetUri.IndexOf(authority) + authority.Length)
Using WindowsIdentity.GetCurrent().Impersonate()
'route the upload thru the upload handler, passing arg to immediately return the report file as our response
Dim clientHandler As New HttpClientHandler() With {
.UseDefaultCredentials = True,
.PreAuthenticate = True}
Using client As New HttpClient(clientHandler) 'here's where we pretend to be a browser
client.Timeout = New TimeSpan(0, 5, 0) '5 min timeout
'build our request content
bytesContent = New ByteArrayContent(LogFileContent) 'don't need the fileStreamContent from the StackOverflow example because we have bytes; they're functionally either/or
formData = New MultipartFormDataContent()
formData.Add(bytesContent, "file1", LogFileName)
formData.Headers.Add("isWs", "1") 'lets the server know the request came from the Web Service
formData.Headers.Add(Constants.RequestHeaders.PickupCode, pickupCode) 'tells the server where to securely place this in cache so we can find it later
#If DEBUG Then
client.Timeout = New TimeSpan(0, 20, 0) 'set timeout to 20 minutes if we're debugging
#End If
'capture response file as binary byte array and return that
'upload the file
uploadHandlerRm = client.PostAsync(targetUri & "/" & Constants.Pages.UploadHandler, formData).Result() 'UploadHandler.ashx
If uploadHandlerRm.IsSuccessStatusCode Then
'visit the ViewReport page and it will automatically emulate a download button click because of our isWs header above
viewReportRm = client.GetAsync(targetUri & "/" & Constants.Pages.ViewReport).Result() 'ViewReport.aspx
If viewReportRm.IsSuccessStatusCode Then
'fetch the cached pdf bytes and return them to client
Return GetBytesByPickupCode(pickupCode) 'fetches the content
Else
Throw New Exception("Upload succeeded, but processing failed: " & viewReportRm.ReasonPhrase)
End If
Else
Throw New Exception("Upload failed: " & uploadHandlerRm.ReasonPhrase)
End If
End Using
End Using
'if we got here, there was an unhandled error of some kind
Throw New Exception("Error during upload, please contact the development team")
Catch ex As Exception
ErrorLogger.LogError(ex) 'this is logged here because when the exception is thrown, the normal Global.asax handler doesn't pick it up
Throw ex
Finally
'erase the return report from cache
If Context.Cache(pickupCode) IsNot Nothing Then Context.Cache.Remove(pickupCode)
End Try
End Function
Relevant (?) web.config bits (maxAllowedContentLength is probably overkill and we may significantly reduce it later)
<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="260000000" />
</requestFiltering>
</security>
The handler works fine on the server for the regular web application (using the asp:FileUpload control) and the web service works fine locally in Visual Studio (ie not using full-blown IIS), so I'm assuming the issue is with how I'm invoking it from the web service, not that the handler has any inherent issues. My servers all have SSL certs per company policy, so I do have to fiddle with my web.config between test and dev environments due to that. Presently, anonymous authentication is not allowed for intranet servers, but is allowed for internet servers: I'm hoping this won't be an issue for the internet servers, but I also need it to work on the intranet, at minimum for testing purposes.
Anyway, no matter what I've tried to do in troubleshooting and diagnostics, the code always fails during uploadHandlerRm = client.PostAsync
in the web service code, with 401 Unauthorized. I am setting UseDefaultCredentials
to true, and the client is sending Windows credentials with Delegation token impersonation rights to the service. Again, the web service and its target ashx handler and aspx page are on the same server (once I get to GET the aspx page, which also fails with 401 if you try to call it without doing the necessary POST first). Via remote debugger, I've also tried setting my own credentials to be the CredentialCache.DefaultCredentials, to no avail.
What I have not tried and would rather not try:
- Testing the application on the (hard to get to) internet test server to see if it works w/ anonymous auth
- Discarding the SOAP web service and:
- writing a local test app method that does the HTTP requests manually
- rewriting the web service as a full blown WCF service
What am I missing? What additional information is needed to troubleshoot?