From 10b127dee13ff8dfe7a67c641f1a24ea1e61b939 Mon Sep 17 00:00:00 2001 From: Joe Bay Date: Wed, 14 Nov 2018 15:02:15 -0500 Subject: [PATCH] Improved presignedURL generation by using URLComponents. PresignedURLs will now sign user defined query parameters. Minor cleanup to improve readability. --- Sources/S3Signer/S3Signer+PresignedURL.swift | 84 ++++++++++++++++++++ Sources/S3Signer/S3Signer+Private.swift | 68 ++++------------ 2 files changed, 99 insertions(+), 53 deletions(-) create mode 100644 Sources/S3Signer/S3Signer+PresignedURL.swift diff --git a/Sources/S3Signer/S3Signer+PresignedURL.swift b/Sources/S3Signer/S3Signer+PresignedURL.swift new file mode 100644 index 0000000..96e1707 --- /dev/null +++ b/Sources/S3Signer/S3Signer+PresignedURL.swift @@ -0,0 +1,84 @@ +import Foundation +import HTTP + +/// Private interface +extension S3Signer { + + private struct PresignedURLAuthQuery { + + var algorithm: String + var credentials: String + var date: String + var expires: String + var signedHeaders: String + + enum Keys: String { + case algorithm = "X-Amz-Algorithm" + case credentials = "X-Amz-Credential" + case date = "X-Amz-Date" + case expires = "X-Amz-Expires" + case signedHeaders = "X-Amz-SignedHeaders" + } + + func queryItems() -> [URLQueryItem] { + return [ + URLQueryItem(name: Keys.algorithm.rawValue, value: algorithm), + URLQueryItem(name: Keys.credentials.rawValue, value: credentials), + URLQueryItem(name: Keys.date.rawValue, value: date), + URLQueryItem(name: Keys.expires.rawValue, value: expires), + URLQueryItem(name: Keys.signedHeaders.rawValue, value: signedHeaders) + ] + } + + } + + func presignedURL(for httpMethod: HTTPMethod, url: URL, expiration: Expiration, region: Region? = nil, headers: [String: String] = [:], dates: Dates) throws -> URL? { + var updatedHeaders = headers + + let region = region ?? config.region + + updatedHeaders["host"] = url.host ?? region.host + + var (canonRequest, urlComponents) = try presignedURLCanonRequest(httpMethod, dates: dates, expiration: expiration, url: url, region: region, headers: updatedHeaders) + + let stringToSign = try createStringToSign(canonRequest, dates: dates, region: region) + let signature = try createSignature(stringToSign, timeStampShort: dates.short, region: region) + urlComponents.queryItems?.insert(URLQueryItem(name: "X-Amz-Signature", value: signature), at: 0) + return urlComponents.url + } + + private func presignedURLCanonRequest(_ httpMethod: HTTPMethod, dates: Dates, expiration: Expiration, url: URL, region: Region, headers: [String: String]) throws -> (String, URLComponents) { + guard let credScope = credentialScope(dates.short, region: region).encode(type: .queryAllowed), + let signHeaders = signed(headers: headers).encode(type: .queryAllowed) else { + throw Error.invalidEncoding + } + + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw Error.badURL(url.absoluteString) + } + + var urlQueryItems = urlComponents.queryItems ?? [] + + let authQuery = PresignedURLAuthQuery(algorithm: "AWS4-HMAC-SHA256", + credentials: config.accessKey, + date: credScope, + expires: "\(expiration.value)", + signedHeaders: signHeaders) + + urlQueryItems.insert(contentsOf: authQuery.queryItems(), at: 0) + urlComponents.queryItems = urlQueryItems + + return ( + [ + httpMethod.string, + formattedPath(urlComponents.path), + formattedQueryItems(urlQueryItems), + canonicalHeaders(headers), + signed(headers: headers), + "UNSIGNED-PAYLOAD" + ].joined(separator: "\n"), + urlComponents + ) + } + +} diff --git a/Sources/S3Signer/S3Signer+Private.swift b/Sources/S3Signer/S3Signer+Private.swift index 03bf1dc..5939d7e 100755 --- a/Sources/S3Signer/S3Signer+Private.swift +++ b/Sources/S3Signer/S3Signer+Private.swift @@ -18,11 +18,10 @@ extension S3Signer { } func createCanonicalRequest(_ httpMethod: HTTPMethod, url: URL, headers: [String: String], bodyDigest: String) throws -> String { - let query = try self.query(url) ?? "" return [ - httpMethod.description, - path(url), - query, + httpMethod.string, + formattedPath(url.path), + formattedQueryString(url), canonicalHeaders(headers), signed(headers: headers), bodyDigest @@ -62,44 +61,22 @@ extension S3Signer { func getDates(_ date: Date) -> Dates { return Dates(date) } - - func path(_ url: URL) -> String { - return !url.path.isEmpty ? url.path.encode(type: .pathAllowed) ?? "/" : "/" - } - - func presignedURLCanonRequest(_ httpMethod: HTTPMethod, dates: Dates, expiration: Expiration, url: URL, region: Region, headers: [String: String]) throws -> (String, URL) { - guard let credScope = credentialScope(dates.short, region: region).encode(type: .queryAllowed), - let signHeaders = signed(headers: headers).encode(type: .queryAllowed) else { - throw Error.invalidEncoding - } - let fullURL = "\(url.absoluteString)?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=\(config.accessKey)%2F\(credScope)&X-Amz-Date=\(dates.long)&X-Amz-Expires=\(expiration.value)&X-Amz-SignedHeaders=\(signHeaders)" - // This should never throw. - guard let url = URL(string: fullURL) else { - throw Error.badURL(fullURL) - } - - let query = try self.query(url) ?? "" - return ( - [ - httpMethod.description, - path(url), - query, - canonicalHeaders(headers), - signed(headers: headers), - "UNSIGNED-PAYLOAD" - ].joined(separator: "\n"), - url - ) + func formattedPath(_ path: String) -> String { + return !path.isEmpty ? path.encode(type: .pathAllowed) ?? "/" : "/" } - - func query(_ url: URL) throws -> String? { + + func formattedQueryString(_ url: URL) -> String { if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { - let items = queryItems.map({ ($0.name.encode(type: .queryAllowed) ?? "", $0.value?.encode(type: .queryAllowed) ?? "") }) - let encodedItems = items.map({ "\($0.0)=\($0.1)" }) - return encodedItems.sorted().joined(separator: "&") + return formattedQueryItems(queryItems) } - return nil + return "" + } + + func formattedQueryItems(_ queryItems: [URLQueryItem]) -> String { + let items = queryItems.map({ ($0.name.encode(type: .queryAllowed) ?? "", $0.value?.encode(type: .queryAllowed) ?? "") }) + let encodedItems = items.map({ "\($0.0)=\($0.1)" }) + return encodedItems.sorted().joined(separator: "&") } func signed(headers: [String: String]) -> String { @@ -122,21 +99,6 @@ extension S3Signer { return updatedHeaders } - func presignedURL(for httpMethod: HTTPMethod, url: URL, expiration: Expiration, region: Region? = nil, headers: [String: String] = [:], dates: Dates) throws -> URL? { - var updatedHeaders = headers - - let region = region ?? config.region - - updatedHeaders["host"] = url.host ?? region.host - - let (canonRequest, fullURL) = try presignedURLCanonRequest(httpMethod, dates: dates, expiration: expiration, url: url, region: region, headers: updatedHeaders) - - let stringToSign = try createStringToSign(canonRequest, dates: dates, region: region) - let signature = try createSignature(stringToSign, timeStampShort: dates.short, region: region) - let presignedURL = URL(string: fullURL.absoluteString.appending("&X-Amz-Signature=\(signature)")) - return presignedURL - } - func headers(for httpMethod: HTTPMethod, urlString: URLRepresentable, region: Region? = nil, headers: [String: String] = [:], payload: Payload, dates: Dates) throws -> HTTPHeaders { guard let url = urlString.convertToURL() else { throw Error.badURL("\(urlString)")