Permalink
Browse files

Merge pull request #3 from dwestheide/signed_uri_generation

Signed uri generation
  • Loading branch information...
2 parents 42358c2 + 20b90a3 commit 5f45208b76bed7f937a2b49a60f65bc118f48afc @n8han committed Oct 27, 2011
Showing with 137 additions and 24 deletions.
  1. +8 −1 .gitignore
  2. +11 −2 README.markdown
  3. +60 −20 src/main/scala/S3.scala
  4. +58 −1 src/test/scala/S3Spec.scala
View
@@ -1,5 +1,12 @@
+.DS_Store
+# IDEA Ignores
+*.iml
+*.ipr
+*.iws
+.idea/
+.idea_modules/
target/
boot/
.DS_Store
.idea
-.idea_modules
+.idea_modules
View
@@ -3,7 +3,10 @@ Amazon S3 module
The `aws-s3` module provides basic support for interacting with Amazon's
S3 service by providing additional handlers which sign the HTTP
-request in accordance with the [S3 authentication specifications][1].
+request in accordance with the [S3 authentication specifications][1].
+It's possible both to sign requests for Authorization Header
+Authentication and to generate signed request URIs that can be handed
+out to third parties for Query String Authentication.
The module also provides a convenience class for interacting with S3
Buckets.
@@ -46,6 +49,12 @@ Creating a file does require you to set the content type of the file upload:
val testFile = new File("testing.txt")
h(b / "testing.txt" <<< (testFile, "plain/text") <@(access_key.get, secret_key.get) >|)
+
+Generation of a signed URI for a GET request with query string authentication
+works as follows:
+
+ val expires = System.currentTimeMillis() / 1000 + 30 * 60
+ (Bucket("my-test-bucket") / "testing.txt").signed(access_key.get, secret_key.get, expires).to_uri
## Testing
@@ -62,4 +71,4 @@ When using sbt 0.11, you can do the following:
After that, you can just run `test` and all of the tests should pass.
-[1]: http://docs.amazonwebservices.com/AmazonS3/index.html?RESTAuthentication.html
+[1]: http://docs.amazonwebservices.com/AmazonS3/latest/dev/index.html?RESTAuthentication.html
View
@@ -1,5 +1,7 @@
package dispatch.s3
+import java.net.URLEncoder._
+import java.lang.Long
import dispatch._
object S3 {
@@ -10,17 +12,32 @@ object S3 {
val UTF_8 = "UTF-8"
+ val Root = "s3.amazonaws.com"
+
object rfc822DateParser extends SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) {
this.setTimeZone(new SimpleTimeZone(0, "GMT"))
}
def trim(s: String): String = s.dropWhile(_ == ' ').reverse.dropWhile(_ == ' ').reverse.toString
+ def md5(bytes: Array[Byte]) = {
+ import java.security.MessageDigest
+
+ val r = MessageDigest.getInstance("MD5")
+ r.reset
+ r.update(bytes)
+ new String(Request.encode_base64(r.digest))
+ }
+
def sign(method: String, path: String, secretKey: String, date: Date,
+ contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String,Set[String]]): String = {
+ sign(method, path, secretKey, Left(date), contentType, contentMd5, amzHeaders)
+ }
+
+ def sign(method: String, path: String, secretKey: String, dateOrExpires: Either[Date, Long],
contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String,Set[String]]) = {
val SHA1 = "HmacSHA1"
- val amzString = amzHeaders.toList.sortWith(_._1.toLowerCase < _._1.toLowerCase).map{ case (k,v) => "%s:%s".format(k.toLowerCase, v.map(trim _).mkString(",")) }
- val message = (method :: contentMd5.getOrElse("") :: contentType.getOrElse("") :: rfc822DateParser.format(date) :: Nil) ++ amzString ++ List(path) mkString("\n")
+ val message = canonicalString(method, path, dateOrExpires, contentType, contentMd5, amzHeaders)
val sig = {
val mac = crypto.Mac.getInstance(SHA1)
val key = new crypto.spec.SecretKeySpec(bytes(secretKey), SHA1)
@@ -30,6 +47,28 @@ object S3 {
sig
}
+ def signedUri(accessKey: String, secretKey: String, method:String, path: String, amzHeaders: Map[String, Set[String]],
+ expires: Long = defaultExpiryTime,
+ contentType: Option[String] = None, contentMd5: Option[String] = None) = {
+ val signed = encode(sign(method, path, secretKey, Right(expires), contentType, contentMd5, amzHeaders), "UTF-8")
+ "%s?Signature=%s&Expires=%s&AWSAccessKeyId=%s".format(path, signed, expires, accessKey)
+ }
+
+ def defaultExpiryTime = System.currentTimeMillis() / 1000 + 600
+
+ /**
+ * @return the canonical request string that needs to be signed for authentication
+ */
+ def canonicalString(method: String, path: String, dateOrExpires: Either[Date, Long], contentType: Option[String],
+ contentMd5: Option[String], amzHeaders: Map[String, Set[String]]) = {
+ val amzString = amzHeaders.toList.sortWith(_._1.toLowerCase < _._1.toLowerCase).map{ case (k,v) => "%s:%s".format(k.toLowerCase, v.map(trim _).mkString(",")) }
+ val dateExpiresString = dateOrExpires match {
+ case Left(date) => rfc822DateParser.format(date)
+ case Right(expires) => expires.toString
+ }
+ (method :: contentMd5.getOrElse("") :: contentType.getOrElse("") :: dateExpiresString :: Nil) ++ amzString ++ List(path) mkString("\n")
+ }
+
def bytes(s: String) = s.getBytes(UTF_8)
implicit def Request2S3RequestSigner(r: Request) = new S3RequestSigner(r)
@@ -41,43 +80,44 @@ object S3 {
type EntityHolder <: org.apache.http.message.BasicHttpEntityEnclosingRequest
- private def md5(bytes: Array[Byte]) = {
- import java.security.MessageDigest
-
- val r = MessageDigest.getInstance("MD5")
- r.reset
- r.update(bytes)
- new String(Request.encode_base64(r.digest))
- }
-
def <@ (accessKey: String, secretKey: String) = {
val req = r.body.map { ent =>
r <:< Map("Content-MD5" -> md5(EntityUtils.toByteArray(new BufferedHttpEntity(ent))))
}.getOrElse(r)
val path = req.to_uri.getPath
-
+
val contentType = req.headers.filter {
case (name, _) => name.toLowerCase == "content-type"
}.headOption.map { case (_, value) => value }.orElse {
req.body.map { _.getContentType.getValue }
}
val contentMd5 = req.headers.filter {
- case (name, _) => name.toLowerCase == "content-md5"
+ case (name, _) => name.toLowerCase == "content-md5"
}.headOption.map { case (_, value) => value }
- val amzHeaders = req.headers.filter {
+ val d = new Date
+ req <:< Map("Authorization" -> "AWS %s:%s".format(accessKey, sign(req.method, path, secretKey, d, contentType, contentMd5, amazonHeaders)),
+ "Date" -> S3.rfc822DateParser.format(d))
+ }
+
+ def signed(accessKey: String, secretKey: String, expires: Long = defaultExpiryTime,
+ contentType: Option[String] = None, contentMd5: Option[String] = None): Request = {
+ val uri = signedUri(accessKey, secretKey, r.method, r.to_uri.getPath, amazonHeaders, expires, contentType, contentMd5)
+ val requestHeaders = for {
+ (key, Some(value)) <- Map("Content-Type" -> contentType, "Content-Md5" -> contentMd5)
+ } yield (key -> value)
+ (:/(S3.Root) / uri.substring(1)).copy(method = r.method, headers = r.headers).secure <:< requestHeaders
+ }
+
+ private def amazonHeaders = r.headers.filter {
case (name, _) => name.toLowerCase.startsWith("x-amz")
- }.foldLeft(Map.empty[String, Set[String]]) { case (m, (name, value)) =>
+ }.foldLeft(Map.empty[String, Set[String]]) { case (m, (name, value)) =>
m + (name -> (m.getOrElse(name, Set.empty[String]) + value))
}
- val d = new Date
- req <:< Map("Authorization" -> "AWS %s:%s".format(accessKey, sign(req.method, path, secretKey, d, contentType, contentMd5, amzHeaders)),
- "Date" -> S3.rfc822DateParser.format(d))
- }
}
}
-class Bucket(val name: String) extends Request(:/("s3.amazonaws.com") / name) {
+class Bucket(val name: String) extends Request(:/(S3.Root) / name) {
val create = this <<< ""
}
object Bucket {
@@ -1,13 +1,16 @@
+import java.util.{TimeZone, Calendar}
import org.specs._
import dispatch._
import s3._
import S3._
import java.io.{File,FileWriter}
+import scala.io.Source
object S3Spec extends Specification {
val UTF_8 = "UTF-8"
- val test = Bucket("databinder-dispatch-s3-test-bucket")
+ val bucketName = "databinder-dispatch-s3-test-bucket"
+ val test = Bucket(bucketName)
val access_key = getValue("awsAccessKey")
val secret_key = getValue("awsSecretAccessKey")
@@ -65,12 +68,66 @@ object S3Spec extends Specification {
case (code, _, _, _) => code must be_==(204)
}
}
+ "create the correct canonical string for a request with a date header" in {
+ val expected = "PUT\nmd5sum\ntext/plain\nMon, 17 Oct 2011 11:43:29 GMT\nx-amz-meta-author:john@doe.com\n/mybucket/newobject123"
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"))
+ cal.set(2011, 9, 17, 11, 43, 29)
+ val date = cal.getTime()
+ val amzHeaders = Map("x-amz-meta-author" -> Set("john@doe.com"))
+ val res = canonicalString("PUT", "/mybucket/newobject123", Left(date), Some("text/plain"), Some("md5sum"), amzHeaders)
+ res must_== expected
+ }
+ "create the correct canonical string for a request with an Expires value" in {
+ val expires = System.currentTimeMillis() / 1000 + 30
+ val expected = "PUT\n\n\n%s\nx-amz-meta-author:john@doe.com\n/mybucket/newobject123".format(expires.toString)
+ val amzHeaders = Map("x-amz-meta-author" -> Set("john@doe.com"))
+ val res = canonicalString("PUT", "/mybucket/newobject123", Right(expires), None, None, amzHeaders)
+ res must_== expected
+ }
+ "create a correct signed URI that can be used by a third party for creating a file" in {
+ shouldWeSkip_?
+ val testFile = newTempFile
+ val expires = System.currentTimeMillis() / 1000 + 30
+ val contentMd5 = md5(Source.fromFile(testFile).map(_.toByte).toArray)
+ val amzHeaders = Map("x-amz-meta-author" -> "john@doe.com")
+ val req = ((test / "testfile123.txt") <:< amzHeaders).PUT.signed(
+ access_key.get, secret_key.get, expires, Some("text/plain"), Some(contentMd5))
+ req.to_uri.toString must startWith ("https://s3.amazonaws.com/databinder-dispatch-s3-test-bucket/testfile123.txt")
+ Http x (req <<< (testFile, "text/plain")) >| {
+ case (code, _, _, _) => {
+ code must be_== (200)
+ }
+ }
+ }
+ "create a correct signed URI that can be used by a third party for retrieving a file" in {
+ shouldWeSkip_?
+ val expires = System.currentTimeMillis() / 1000 + 30
+ val req = (test / "testfile123.txt").signed(access_key.get, secret_key.get, expires)
+ req.to_uri.toString must startWith ("https://s3.amazonaws.com/databinder-dispatch-s3-test-bucket/testfile123.txt")
+ Http x(req as_str) {
+ case (code, _, _, str) => {
+ code must be_==(200)
+ str() must be_==("testing")
+ }
+ }
+ }
+ "create a correct signed URI that can be used by a third party for deleting a file" in {
+ shouldWeSkip_?
+ val expires = System.currentTimeMillis() / 1000 + 30
+ val req = (test / "testfile123.txt").DELETE signed (access_key.get, secret_key.get, expires)
+ Http x req >| {
+ case (code, _, _, _) => {
+ code must be_== (204)
+ }
+ }
+ }
"be able to delete a bucket" in {
shouldWeSkip_?
Http x (test.DELETE <@ (access_key.get, secret_key.get) >| ) {
case (code, _, _, _) => code must be_==(204)
}
}
+
}
doAfterSpec {

0 comments on commit 5f45208

Please sign in to comment.