Search code examples
phpmacosfile-uploadbasic-authenticationnsurlsessionuploadtask

Why does NSURLSessionUploadTask to PHP endpoint with basic authentication send data twice?


I am using an NSURLSessionUploadTask via an NSURLSession on macOS 10.12 Sierra with a custom delegate to upload a local file to an Apache server with a PHP script that requests basic authentication. It works except that the upload task appears to send the complete file data then is prompted by the server for the NSURLAuthenticationChallenge and, upon sending the proper credential, the upload task sends the entire data payload again. I would expect that the basic authentication challenge would come before the upload or that if it does come after the upload, that once confirmed, the data already uploaded would be accepted and not uploaded a second time. Any help getting the uploaded data to be posted only once would be most appreciated.

Endpoint script uploader.php:

<?php
$u = $_SERVER['PHP_AUTH_USER'];
$p = $_SERVER['PHP_AUTH_PW'];
if (($u !== 'user') || ($p !== 'password')) {
    header('WWW-Authenticate: Basic realm="Restricted Area"');
    header('HTTP/1.0 401 Unauthorized');
    die('<h1>401 Unauthorized</h1>Access Denied.');
}
$response = 'file upload failed: upload not specified';
if (isset($_FILES['upload'])) {
    $file_tmp_name = $_FILES['upload']['tmp_name'];
    $file_name = $_FILES['upload']['name'];
    $file_name_new = ('uploads/' . stripslashes($file_name));
    if (!is_writable(dirname($file_name_new))) {
        $response = 'file upload failed: directory is not writable.';
    } else {
        if (!move_uploaded_file($file_tmp_name, $file_name_new)) {
            $response = 'file upload failed: couldn\'t move file to ' . $new_name;
        } else {
            $response = $file_name_new;
        }
    }
}
echo($response);
?>

FileUploader.m:

- (void)startUpload {
    NSLog(@"starting upload");

    NSURL *url = [NSURL URLWithString:@"https://www.domain.com/uploader.php"];
    NSString *localPath = @"/path/to/file.ext";
    NSString *inputName = @"upload";

    NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:30.0];
    request.HTTPMethod = @"POST";
    NSString *boundary = [NSString stringWithFormat:@"x-mime-boundary://%@", [NSUUID UUID].UUIDString];
    NSString *contentType = [NSString stringWithFormat:@"multipart/form-data; boundary=%@", boundary];
    [request setValue:contentType forHTTPHeaderField:@"Content-Type"];
    [request setValue:[NSBundle mainBundle].bundleIdentifier forHTTPHeaderField:@"User-Agent"];

    NSMutableData *postData = [NSMutableData data];
    [postData appendData:[[NSString stringWithFormat:@"\r\n--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
    [postData appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; type=\"file\"; filename=\"%@\"\r\n\r\n", inputName, localPath.lastPathComponent] dataUsingEncoding:NSUTF8StringEncoding]];
    [postData appendData:[NSData dataWithContentsOfFile:localPath]];
    [postData appendData:[[NSString stringWithFormat:@"\r\n\r\n--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
    [request setValue:[NSString stringWithFormat:@"%ld", postData.length] forHTTPHeaderField:@"Content-Length"];

    NSURLSessionConfiguration *defaultConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
    self.session = [NSURLSession sessionWithConfiguration:defaultConfiguration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
    [[self.session uploadTaskWithRequest:request fromData:[NSData dataWithData:postData]] resume];
}

- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler {
    NSLog(@"URLSession didReceiveChallenge: %@", challenge.protectionSpace.authenticationMethod);
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
        NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
        completionHandler(((credential) ? NSURLSessionAuthChallengePerformDefaultHandling : NSURLSessionAuthChallengeUseCredential), credential);
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler {
    NSLog(@"NSURLSessionTask didReceiveChallenge: %@", challenge.protectionSpace.authenticationMethod);
    NSString *username = @"user";
    NSString *password = @"password";
    NSURLCredential *credential = [NSURLCredential credentialWithUser:username password:password persistence:NSURLCredentialPersistenceForSession];
    completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didSendBodyData:(int64_t)bytesSent totalBytesSent:(int64_t)totalBytesSent totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
    NSLog(@"sent %ld b of %ld b (%.1f%%)", (long)totalBytesSent, (long)totalBytesExpectedToSend, (((float)totalBytesSent / (float)totalBytesExpectedToSend) * 100.0));
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    NSLog(@"upload complete");
    if (error) [NSApp presentError:error];
    [self.session invalidateAndCancel];
    self.session = nil;
}

Abridged console output:

2016-10-11 12:14:34.323485 FileUploader[23624:5580925] starting upload
2016-10-11 12:14:34.429419 FileUploader[23624:5580925] URLSession didReceiveChallenge: NSURLAuthenticationMethodServerTrust
2016-10-11 12:14:34.459239 FileUploader[23624:5580925] sent 32768 b of 10616647 b (0.3%)
2016-10-11 12:14:34.459351 FileUploader[23624:5580925] sent 65536 b of 10616647 b (0.6%)
...
2016-10-11 12:14:42.849080 FileUploader[23624:5580925] sent 10584064 b of 10616647 b (99.7%)
2016-10-11 12:14:42.849179 FileUploader[23624:5580925] sent 10616647 b of 10616647 b (100.0%)
2016-10-11 12:14:43.038092 FileUploader[23624:5580925] NSURLSessionTask didReceiveChallenge: NSURLAuthenticationMethodHTTPBasic
2016-10-11 12:14:43.040085 FileUploader[23624:5580925] sent 10649415 b of 21233294 b (50.2%)
2016-10-11 12:14:43.040141 FileUploader[23624:5580925] sent 10682183 b of 21233294 b (50.3%)
...
2016-10-11 12:14:46.508339 FileUploader[23624:5580925] sent 21200711 b of 21233294 b (99.8%)
2016-10-11 12:14:46.594864 FileUploader[23624:5580925] sent 21233294 b of 21233294 b (100.0%)
2016-10-11 12:14:46.757213 FileUploader[23624:5580925] upload complete

Solution

  • There are several ways to solve this. The first two that come to mind are:

    • Send an explicit HEAD or GET request before you send the POST request to verify the credentials. This will work >99% of the time.
    • Instead of sending an authentication error code, send a blob of JSON that your app can recognize as an error, and in that JSON blob, provide a UUID for the previous upload that your app can then provide in a new request to associate the upload with the user's account. This will work 100% of the time, but you'll need to add a cron job on the server to periodically delete old files.

    Either approach involves client-side and server-side changes.