Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add S3.generatePresignedPost for HTML form based uploads #710

Merged
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 261 additions & 0 deletions Sources/Soto/Extensions/S3/S3+presignedPost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Soto for AWS open source project
//
// Copyright (c) 2024 the Soto project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Soto project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Foundation
import Logging

import Crypto
@_spi(SotoInternal) import SotoSignerV4

extension S3ErrorType {
public enum presignedPost: Error {
case malformedEndpointURL
case malformedBucketURL
}
}

extension S3 {
/// An encodable struct that represents acceptable values for the fields
/// supplied in a presigned POST request
public struct PostPolicy: Encodable {
let expiration: Date
let conditions: [PostPolicyCondition]

func stringToSign() throws -> String {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let policyData = try encoder.encode(self)
let base64encoded = policyData.base64EncodedString()

return base64encoded
}
}

/// A condition for use in a PostPolicy, which can represent an exact match
/// on the value for a particular field, or a rule that allows for other
/// types of matches, eg. "starts-with"
public enum PostPolicyCondition: Encodable {
case match(String, String)
case rule(String, String, String)

public func encode(to encoder: Encoder) throws {
switch self {
case .match(let field, let value):
let condition = [field: value]
var container = encoder.singleValueContainer()
try container.encode(condition)

case .rule(let rule, let field, let value):
let condition = [rule, field, value]
var container = encoder.singleValueContainer()
try container.encode(condition)
}
}
}

/// An encodable struct that represents the URL and form fields to use in a
/// presigned POST request to S3
public struct PresignedPostResponse: Encodable {
let url: URL
let fields: [String: String]
}

/// Builds the url and the form fields used for a presigned s3 post
/// - Parameters:
/// - key: Key name, optionally add ${filename} to the end to attach the
/// submitted filename. Note that key related conditions and fields are
/// filled out for you and should not be included in the Fields or
/// Conditions parameter.
/// - bucket: The name of the bucket to presign the post to. Note that
/// bucket related conditions should not be included in the conditions parameter.
/// - fields: A dictionary of prefilled form fields to build on top of.
/// Elements that may be included are acl, Cache-Control, Content-Type,
/// Content-Disposition, Content-Encoding, Expires,
/// success_action_redirect, redirect, success_action_status, and x-amz-meta-.
///
/// Note that if a particular element is included in the fields
/// dictionary it will not be automatically added to the conditions
/// list. You must specify a condition for the element as well.
/// - conditions: A list of conditions to include in the policy. Each
/// element can be either a match or a rule. For example:
///
/// ```
/// [
/// .match("acl", "public-read"),
/// .rule("content-length-range", "2", "5"),
/// .rule("starts-with", "$success_action_redirect", "")
/// ]
/// ```
///
/// Conditions that are included may pertain to acl, content-length-range,
/// Cache-Control, Content-Type, Content-Disposition, Content-Encoding,
/// Expires, success_action_redirect, redirect, success_action_status,
/// and/or x-amz-meta-.
///
/// Note that if you include a condition, you must specify the a valid
/// value in the fields dictionary as well. A value will not be added
/// automatically to the fields dictionary based on the conditions.
/// - expiresIn: The number of seconds the presigned post is valid for.
/// - Returns: An encodable PresignedPostResponse with two properties: url
/// and fields. Url is the url to post to. Fields is a dictionary filled
/// with the form fields and respective values to use when submitting the post.
public func generatePresignedPost(
key: String,
bucket: String,
fields: [String: String] = [:],
conditions: [PostPolicyCondition] = [],
expiresIn: TimeInterval
) async throws -> PresignedPostResponse {
try await self.generatePresignedPost(
key: key,
bucket: bucket,
fields: fields,
conditions: conditions,
expiresIn: expiresIn,
date: Date()
)
}

// Private API adds date argument for testing
func generatePresignedPost(
key: String,
bucket: String,
fields: [String: String] = [:],
conditions: [PostPolicyCondition] = [],
expiresIn: TimeInterval,
date: Date = Date()
) async throws -> PresignedPostResponse {
// Copy the fields and conditions to a variable
var fields = fields
var conditions: [PostPolicyCondition] = conditions

// Update endpoint URL to include the bucket
guard let url = URL(string: endpoint) else {
throw S3ErrorType.presignedPost.malformedEndpointURL
}

guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
throw S3ErrorType.presignedPost.malformedEndpointURL
}

guard let host = components.host else {
throw S3ErrorType.presignedPost.malformedEndpointURL
}

components.host = "\(bucket).\(host)"

guard let url = components.url else {
throw S3ErrorType.presignedPost.malformedBucketURL
}

// Gather canonical values
let algorithm = "AWS4-HMAC-SHA256" // Get signature version from client?

let longDate = self.longDateFormat(date: date)
let shortDate = self.shortDateFormat(date: date)

let clientCredentials = try await client.getCredential()
let presignedPostCredential = getPresignedPostCredential(date: shortDate, accessKeyId: clientCredentials.accessKeyId)

var keyCondition: PostPolicyCondition
let suffix = "${filename}"
if key.hasSuffix(suffix) {
keyCondition = .rule("starts-with", "$key", String(key.dropLast(suffix.count)))
} else {
keyCondition = .match("key", key)
}

// Add required conditions
conditions.append(.match("bucket", bucket))
conditions.append(keyCondition)
conditions.append(.match("x-amz-algorithm", algorithm))
conditions.append(.match("x-amz-date", longDate))
conditions.append(.match("x-amz-credential", presignedPostCredential))

// Add required fields
fields["key"] = key
fields["x-amz-algorithm"] = algorithm
fields["x-amz-date"] = longDate
fields["x-amz-credential"] = presignedPostCredential

// Create the policy and add to fields
let policy = PostPolicy(expiration: date.addingTimeInterval(expiresIn), conditions: conditions)
let stringToSign = try policy.stringToSign()

fields["Policy"] = stringToSign

// Create the signature and add to fields
let signingKey = signingKey(date: shortDate, secretAccessKey: clientCredentials.secretAccessKey)
let signature = getSignature(policy: stringToSign, signingKey: signingKey)
fields["x-amz-signature"] = signature

// Create the response
let presignedPostResponse = PresignedPostResponse(url: url, fields: fields)

return presignedPostResponse
}

func signingKey(date: String, secretAccessKey: String) -> SymmetricKey {
let name = config.signingName
let region = config.region.rawValue

let kDate = HMAC<SHA256>.authenticationCode(for: [UInt8](date.utf8), using: SymmetricKey(data: Array("AWS4\(secretAccessKey)".utf8)))
let kRegion = HMAC<SHA256>.authenticationCode(for: [UInt8](region.utf8), using: SymmetricKey(data: kDate))
let kService = HMAC<SHA256>.authenticationCode(for: [UInt8](name.utf8), using: SymmetricKey(data: kRegion))
let kSigning = HMAC<SHA256>.authenticationCode(for: [UInt8]("aws4_request".utf8), using: SymmetricKey(data: kService))
return SymmetricKey(data: kSigning)
}

func getSignature(policy: String, signingKey key: SymmetricKey) -> String {
let signature = HMAC<SHA256>.authenticationCode(for: [UInt8](policy.utf8), using: key).hexDigest()
return signature
}

func getPresignedPostCredential(date: String, accessKeyId: String) -> String {
let region = config.region.rawValue
let service = config.signingName

let credential = "\(accessKeyId)/\(date)/\(region)/\(service)/aws4_request"
return credential
}

private func shortDateFormat(date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions.insert(.withYear)
formatter.formatOptions.insert(.withMonth)
formatter.formatOptions.insert(.withDay)
formatter.formatOptions.remove(.withTime)
formatter.formatOptions.remove(.withTimeZone)
formatter.formatOptions.remove(.withDashSeparatorInDate)

let formattedDate = formatter.string(from: date)

return formattedDate
}

private func longDateFormat(date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions.insert(.withYear)
formatter.formatOptions.insert(.withMonth)
formatter.formatOptions.insert(.withDay)
formatter.formatOptions.insert(.withTime)
formatter.formatOptions.insert(.withTimeZone)
formatter.formatOptions.remove(.withDashSeparatorInDate)
formatter.formatOptions.remove(.withColonSeparatorInTime)

let formattedDate = formatter.string(from: date)

return formattedDate
}
}
118 changes: 118 additions & 0 deletions Tests/SotoTests/Services/S3/S3ExtensionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,122 @@ extension S3Tests {
let request2 = try AWSHTTPRequest(operation: "PutObject", path: "/{Bucket}/{Key+}?x-id=PutObject", method: .PUT, input: input2, configuration: s3.config)
XCTAssertEqual(request2.headers["Content-MD5"].first, "JhF7IaLE189bvT4/iv/iqg==")
}

func testGeneratePresignedPost() async throws {
// Based on the example here: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html

// Credentials are example only, from the above documentation
let client = AWSClient(
credentialProvider: .static(
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
),
httpClientProvider: .createNew
)
let s3 = S3(client: client, region: .useast1)

defer { try? client.syncShutdown() }

let fields = [
"acl": "public-read",
"success_action_redirect": "http://sigv4examplebucket.s3.amazonaws.com/successful_upload.html",
"x-amz-meta-uuid": "14365123651274",
"x-amz-server-side-encryption": "AES256",
]

let conditions: [S3.PostPolicyCondition] = [
.match("acl", "public-read"),
.match("success_action_redirect", "http://sigv4examplebucket.s3.amazonaws.com/successful_upload.html"),
.match("x-amz-meta-uuid", "14365123651274"),
.match("x-amz-server-side-encryption", "AES256"),
.rule("starts-with", "$Content-Type", "image/"),
.rule("starts-with", "$x-amz-meta-tag", "")
]

let expiresIn = 36.0 * 60.0 * 60.0
var dateComponents = DateComponents()
dateComponents.year = 2015
dateComponents.month = 12
dateComponents.day = 29
dateComponents.timeZone = TimeZone(secondsFromGMT: 0)!

let date = Calendar(identifier: .gregorian).date(from: dateComponents)!

let presignedPost = try await s3.generatePresignedPost(
key: "user/user1/${filename}",
bucket: "sigv4examplebucket",
fields: fields,
conditions: conditions,
expiresIn: expiresIn,
date: date
)

let expectedURL = "https://sigv4examplebucket.s3.us-east-1.amazonaws.com"
let expectedCredential = "AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request"
let expectedAlgorithm = "AWS4-HMAC-SHA256"
let expectedDate = "20151229T000000Z"

XCTAssertEqual(presignedPost.url, URL(string: expectedURL))
XCTAssertEqual(presignedPost.fields["x-amz-credential"], expectedCredential)
XCTAssertEqual(presignedPost.fields["x-amz-algorithm"], expectedAlgorithm)
XCTAssertEqual(presignedPost.fields["x-amz-date"], expectedDate)

XCTAssertNotNil(presignedPost.fields["x-amz-signature"])
XCTAssertNotNil(presignedPost.fields["Policy"])
}

func testGetSignature() {
// Based on the example here: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html

// Credentials are example only, from the above documentation
let client = AWSClient(
credentialProvider: .static(
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
),
httpClientProvider: .createNew
)
let s3 = S3(client: client, region: .useast1)

defer { try? client.syncShutdown() }

let policy = "eyAiZXhwaXJhdGlvbiI6ICIyMDE1LTEyLTMwVDEyOjAwOjAwLjAwMFoiLA0KICAiY29uZGl0aW9ucyI6IFsNCiAgICB7ImJ1Y2tldCI6ICJzaWd2NGV4YW1wbGVidWNrZXQifSwNCiAgICBbInN0YXJ0cy13aXRoIiwgIiRrZXkiLCAidXNlci91c2VyMS8iXSwNCiAgICB7ImFjbCI6ICJwdWJsaWMtcmVhZCJ9LA0KICAgIHsic3VjY2Vzc19hY3Rpb25fcmVkaXJlY3QiOiAiaHR0cDovL3NpZ3Y0ZXhhbXBsZWJ1Y2tldC5zMy5hbWF6b25hd3MuY29tL3N1Y2Nlc3NmdWxfdXBsb2FkLmh0bWwifSwNCiAgICBbInN0YXJ0cy13aXRoIiwgIiRDb250ZW50LVR5cGUiLCAiaW1hZ2UvIl0sDQogICAgeyJ4LWFtei1tZXRhLXV1aWQiOiAiMTQzNjUxMjM2NTEyNzQifSwNCiAgICB7IngtYW16LXNlcnZlci1zaWRlLWVuY3J5cHRpb24iOiAiQUVTMjU2In0sDQogICAgWyJzdGFydHMtd2l0aCIsICIkeC1hbXotbWV0YS10YWciLCAiIl0sDQoNCiAgICB7IngtYW16LWNyZWRlbnRpYWwiOiAiQUtJQUlPU0ZPRE5ON0VYQU1QTEUvMjAxNTEyMjkvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LA0KICAgIHsieC1hbXotYWxnb3JpdGhtIjogIkFXUzQtSE1BQy1TSEEyNTYifSwNCiAgICB7IngtYW16LWRhdGUiOiAiMjAxNTEyMjlUMDAwMDAwWiIgfQ0KICBdDQp9"
let signingKey = s3.signingKey(
date: "20151229",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)
let signature = s3.getSignature(
policy: policy,
signingKey: signingKey
)

let expectedSignature = "8afdbf4008c03f22c2cd3cdb72e4afbb1f6a588f3255ac628749a66d7f09699e"

XCTAssertEqual(signature, expectedSignature)
}

func testGetPresignedPostCredential() {
// Based on the example here: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html

// Credentials are example only, from the above documentation
let client = AWSClient(
credentialProvider: .static(
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
),
httpClientProvider: .createNew
)
let s3 = S3(client: client, region: .useast1)

defer { try? client.syncShutdown() }

let credential = s3.getPresignedPostCredential(
date: "20151229",
accessKeyId: "AKIAIOSFODNN7EXAMPLE"
)

let expectedCredential = "AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request"

XCTAssertEqual(credential, expectedCredential)
}
}
Loading