1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416
|
-----------------------------------------------------------------------------
-- |
-- Module : Network.AWS.Authentication
-- Copyright : (c) Greg Heartsfield 2007
-- License : BSD3
--
-- Implements authentication and low-level communication with Amazon
-- Web Services, such as S3, EC2, and others.
-- API Version 2006-03-01
-- <http://docs.amazonwebservices.com/AmazonS3/2006-03-01/>
-----------------------------------------------------------------------------
module Network.AWS.Authentication (
-- * Function Types
runAction, isAmzHeader, preSignedURI,
-- * Data Types
S3Action(..),
-- * Misc functions
mimeEncodeQP, mimeDecode
) where
import Network.AWS.AWSResult
import Network.AWS.AWSConnection
import Network.AWS.ArrowUtils
import Network.HTTP as HTTP hiding (simpleHTTP_)
import Network.HTTP.HandleStream (simpleHTTP_)
import Network.Stream (Result)
import Network.URI as URI
import qualified Data.ByteString.Lazy.Char8 as L
import Data.ByteString.Char8 (pack, unpack)
import Data.HMAC
import Codec.Binary.Base64 (encode, decode)
import Codec.Utils (Octet)
import Data.Char (intToDigit, digitToInt, ord, chr, toLower)
import Data.Bits ((.&.))
import qualified Codec.Binary.UTF8.String as US
import Data.List (sortBy, groupBy, intersperse, isInfixOf)
import Data.Maybe
import System.Time
import System.Locale
import Text.Regex
import Control.Arrow
import Control.Arrow.ArrowTree
import Text.XML.HXT.Arrow.XmlArrow
import Text.XML.HXT.Arrow.XmlOptions
import Text.XML.HXT.DOM.XmlKeywords
import Text.XML.HXT.Arrow.XmlState
import Text.XML.HXT.Arrow.ReadDocument
-- | An action to be performed using S3.
data S3Action =
S3Action {
-- | Connection and authentication information
s3conn :: AWSConnection,
-- | Name of bucket to act on (URL encoded)
s3bucket :: String,
-- | Name of object to act on (URL encoded)
s3object :: String,
-- | Query parameters (requires a prefix of @?@)
s3query :: String,
-- | Additional header fields to send
s3metadata :: [(String, String)],
-- | Body of action, if sending data
s3body :: L.ByteString,
-- | Type of action, 'PUT', 'GET', etc.
s3operation :: RequestMethod
} deriving (Show)
-- | Transform an 'S3Action' into an HTTP request. Does not add
-- authentication or date information, so it is not suitable for
-- sending directly to AWS.
requestFromAction :: S3Action -- ^ Action to transform
-> HTTP.HTTPRequest L.ByteString -- ^ Action represented as an HTTP Request.
requestFromAction a =
Request { rqURI = URI { uriScheme = "",
uriAuthority = Nothing,
uriPath = qpath,
uriQuery = s3query a,
uriFragment = "" },
rqMethod = s3operation a,
rqHeaders = Header HdrHost (s3Hostname a) :
headersFromAction a,
rqBody = (s3body a)
}
where qpath = '/' : s3object a
-- | Create 'Header' objects from an action.
headersFromAction :: S3Action
-> [Header]
headersFromAction = map (\(k,v) -> case k of
"Content-Type" -> Header HdrContentType v
"Content-Length" -> Header HdrContentLength v
otherwise -> Header (HdrCustom k) (mimeEncodeQP v))
. s3metadata
-- | Inspect HTTP body, and add a @Content-Length@ header with the
-- correct length, if it does not already exist.
addContentLengthHeader :: HTTP.HTTPRequest L.ByteString -> HTTP.HTTPRequest L.ByteString
addContentLengthHeader req = insertHeaderIfMissing HdrContentLength conlength req
where conlength = show (L.length (rqBody req))
-- | Add AWS authentication header to an HTTP request.
addAuthenticationHeader :: S3Action -- ^ Action with authentication data
-> HTTP.HTTPRequest L.ByteString -- ^ Request to transform
-> HTTP.HTTPRequest L.ByteString -- ^ Authenticated request
addAuthenticationHeader act req = insertHeader HdrAuthorization auth_string req
where auth_string = "AWS " ++ awsAccessKey conn ++ ":" ++ signature
signature = (makeSignature conn (stringToSign act req))
conn = s3conn act
-- | Sign a string using the given authentication data
makeSignature :: AWSConnection -- ^ Action with authentication data
-> String -- ^ String to sign
-> String -- ^ Base-64 encoded signature
makeSignature c s =
encode (hmac_sha1 keyOctets msgOctets)
where keyOctets = string2words (awsSecretKey c)
msgOctets = string2words s
-- | Generate text that will be signed and subsequently added to the
-- request.
stringToSign :: S3Action -> HTTP.HTTPRequest L.ByteString -> String
stringToSign a r =
canonicalizeHeaders r ++
canonicalizeAmzHeaders r ++
canonicalizeResource a
-- | Extract header data needed for signing.
canonicalizeHeaders :: HTTP.HTTPRequest L.ByteString -> String
canonicalizeHeaders r =
http_verb ++ "\n" ++
hdr_content_md5 ++ "\n" ++
hdr_content_type ++ "\n" ++
dateOrExpiration ++ "\n"
where http_verb = show (rqMethod r)
hdr_content_md5 = get_header HdrContentMD5
hdr_date = get_header HdrDate
hdr_content_type = get_header HdrContentType
get_header h = fromMaybe "" (findHeader h r)
dateOrExpiration = fromMaybe hdr_date (findHeader HdrExpires r)
-- | Extract @x-amz-*@ headers needed for signing.
-- find all headers with type HdrCustom that begin with amzHeader
-- lowercase key names
-- sort lexigraphically by key name
-- combine headers with same name
-- unfold multi-line headers
-- trim whitespace around the header
canonicalizeAmzHeaders :: HTTP.HTTPRequest L.ByteString -> String
canonicalizeAmzHeaders r =
let amzHeaders = filter isAmzHeader (rqHeaders r)
amzHeaderKV = map headerToLCKeyValue amzHeaders
sortedGroupedHeaders = groupHeaders (sortHeaders amzHeaderKV)
uniqueHeaders = combineHeaders sortedGroupedHeaders
in concatMap (\a -> a ++ "\n") (map showHeader uniqueHeaders)
-- | Give the string representation of a (key,value) header pair.
-- Uses rules for authenticated headers.
showHeader :: (String, String) -> String
showHeader (k,v) = k ++ ":" ++ removeLeadingTrailingWhitespace(fold_whitespace v)
-- | Replace CRLF followed by whitespace with a single space
fold_whitespace :: String -> String
fold_whitespace s = subRegex (mkRegex "\n\r( |\t)+") s " "
-- | strip leading/trailing whitespace
removeLeadingTrailingWhitespace :: String -> String
removeLeadingTrailingWhitespace s = subRegex (mkRegex "^\\s+") (subRegex (mkRegex "\\s+$") s "") ""
-- | Combine same-named headers.
combineHeaders :: [[(String, String)]] -> [(String, String)]
combineHeaders = map mergeSameHeaders
-- | Headers with same name should have values merged.
mergeSameHeaders :: [(String, String)] -> (String, String)
mergeSameHeaders h@(x:_) = let values = map snd h
in ((fst x), (concat $ intersperse "," values))
-- | Group headers with the same name.
groupHeaders :: [(String, String)] -> [[(String, String)]]
groupHeaders = groupBy (\a b -> fst a == fst b)
-- | Sort by key name.
sortHeaders :: [(String, String)] -> [(String, String)]
sortHeaders = sortBy (\a b -> fst a `compare` fst b)
-- | Make 'Header' easier to work with, and lowercase keys.
headerToLCKeyValue :: Header -> (String, String)
headerToLCKeyValue (Header k v) = (map toLower (show k), v)
-- | Determine if a header belongs in the StringToSign
isAmzHeader :: Header -> Bool
isAmzHeader h =
case h of
Header (HdrCustom k) _ -> isPrefix amzHeader k
otherwise -> False
-- | is the first list a prefix of the second?
isPrefix :: Eq a => [a] -> [a] -> Bool
isPrefix a b = a == take (length a) b
-- | Prefix used by Amazon metadata headers
amzHeader :: String
amzHeader = "x-amz-"
-- | Extract resource name, as required for signing.
canonicalizeResource :: S3Action -> String
canonicalizeResource a = bucket ++ uri ++ subresource
where uri = '/' : s3object a
bucket = case (s3bucket a) of
b@(_:_) -> '/' : map toLower b
otherwise -> ""
subresource = case (subresource_match) of
[] -> ""
x:_ -> x
subresource_match = filter (\sr -> isInfixOf sr (s3query a))
["?versioning", "?torrent", "?logging", "?acl", "?location"]
-- | Add a date string to a request.
addDateToReq :: HTTP.HTTPRequest L.ByteString -- ^ Request to modify
-> String -- ^ Date string, in RFC 2616 format
-> HTTP.HTTPRequest L.ByteString-- ^ Request with date header added
addDateToReq r d = r {HTTP.rqHeaders =
HTTP.Header HTTP.HdrDate d : HTTP.rqHeaders r}
-- | Add an expiration date to a request.
addExpirationToReq :: HTTP.HTTPRequest L.ByteString -> String -> HTTP.HTTPRequest L.ByteString
addExpirationToReq r = addHeaderToReq r . HTTP.Header HTTP.HdrExpires
-- | Attach an HTTP header to a request.
addHeaderToReq :: HTTP.HTTPRequest L.ByteString -> Header -> HTTP.HTTPRequest L.ByteString
addHeaderToReq r h = r {HTTP.rqHeaders = h : HTTP.rqHeaders r}
-- | Get hostname to connect to. Needed for european buckets
s3Hostname :: S3Action -> String
s3Hostname a =
let s3host = awsHost (s3conn a) in
case (s3bucket a) of
b@(_:_) -> b ++ "." ++ s3host
otherwise -> s3host
-- | Get current time in HTTP 1.1 format (RFC 2616)
-- Numeric time zones should be used, but I'd rather not subvert the
-- intent of ctTZName, so we stick with the name format. Otherwise,
-- we could send @+0000@ instead of @GMT@.
-- see:
-- <http://www.ietf.org/rfc/rfc2616.txt>
-- <http://www.ietf.org/rfc/rfc1123.txt>
-- <http://www.ietf.org/rfc/rfc822.txt>
httpCurrentDate :: IO String
httpCurrentDate =
do c <- getClockTime
let utc_time = (toUTCTime c) {ctTZName = "GMT"}
return $ formatCalendarTime defaultTimeLocale rfc822DateFormat utc_time
-- | Convenience for dealing with HMAC-SHA1
string2words :: String -> [Octet]
string2words = US.encode
-- | Construct the request specified by an S3Action, send to Amazon,
-- and return the response. Todo: add MD5 signature.
runAction :: S3Action -> IO (AWSResult (HTTPResponse L.ByteString))
runAction a = runAction' a (s3Hostname a)
runAction' :: S3Action -> String -> IO (AWSResult (HTTPResponse L.ByteString))
runAction' a hostname = do
c <- (openTCPConnection hostname (awsPort (s3conn a)))
--bufferOps = lazyBufferOp
cd <- httpCurrentDate
let aReq = addAuthenticationHeader a $
addContentLengthHeader $
addDateToReq (requestFromAction a) cd
--print aReq -- Show request header
result <- simpleHTTP_ c aReq
-- Show result header and body
--print result
--case result of
-- Left a -> print ""
-- Right a -> print (rspBody a)
close c
createAWSResult a result
-- | Construct a pre-signed URI, but don't act on it. This is useful
-- for when an expiration date has been set, and the URI needs to be
-- passed on to a client.
preSignedURI :: S3Action -- ^ Action with resource
-> Integer -- ^ Expiration time, in seconds since
-- 00:00:00 UTC on January 1, 1970
-> URI -- ^ URI of resource
preSignedURI a e =
let c = (s3conn a)
srv = (awsHost c)
pt = (show (awsPort c))
accessKeyQuery = "AWSAccessKeyId=" ++ awsAccessKey c
beginQuery = case (s3query a) of
"" -> "?"
x -> x ++ "&"
expireQuery = "Expires=" ++ show e
toSign = "GET\n\n\n" ++ show e ++ "\n/" ++ s3bucket a ++ "/" ++ s3object a
sigQuery = "Signature=" ++ urlEncode (makeSignature c toSign)
q = beginQuery ++ accessKeyQuery ++ "&" ++
expireQuery ++ "&" ++ sigQuery
in URI { uriScheme = "http:",
uriAuthority = Just (URIAuth "" srv (':' : pt)),
uriPath = "/" ++ s3bucket a ++ "/" ++ s3object a,
uriQuery = q,
uriFragment = ""
}
-- | Inspect a response for network errors, HTTP error codes, and
-- Amazon error messages.
-- We need the original action in case we get a 307 (temporary redirect)
createAWSResult :: S3Action -> Result (HTTPResponse L.ByteString) -> IO (AWSResult (HTTPResponse L.ByteString))
createAWSResult a b = either handleError handleSuccess b
where handleError = return . Left . NetworkError
handleSuccess s = case (rspCode s) of
(2,_,_) -> return (Right s)
-- temporary redirect
(3,0,7) -> case (findHeader HdrLocation s) of
Just l -> runAction' a (getHostname l)
Nothing -> return (Left $ AWSError "Temporary Redirect" "Redirect without location header") -- not good
(4,0,4) -> return (Left $ AWSError "NotFound" "404 Not Found") -- no body, so no XML to parse
otherwise -> do e <- parseRestErrorXML (L.unpack (rspBody s))
return (Left e)
-- Get hostname part from http url.
getHostname :: String -> String
getHostname h = case parseURI h of
Just u -> case (uriAuthority u) of
Just auth -> (uriRegName auth)
Nothing -> ""
Nothing -> ""
-- | Find the errors embedded in an XML message body from Amazon.
parseRestErrorXML :: String -> IO ReqError
parseRestErrorXML x =
do e <- runX (readString [withValidate no] x
>>> processRestError)
case e of
[] -> return (AWSError "NoErrorInMsg"
("HTTP Error condition, but message body"
++ "did not contain error code."))
x:xs -> return x
-- | Find children of @Error@ entity, use their @Code@ and @Message@
-- entities to create an 'AWSError'.
processRestError = deep (isElem >>> hasName "Error") >>>
split >>> first (text <<< atTag "Code") >>>
second (text <<< atTag "Message") >>>
unsplit (\x y -> AWSError x y)
--- mime header encoding
mimeEncodeQP, mimeDecode :: String -> String
-- | Decode a mime string, we know about quoted printable and base64 encoded UTF-8
-- S3 may convert quoted printable to base64
mimeDecode a
| isPrefix utf8qp a =
mimeDecodeQP $ encoded_payload utf8qp a
| isPrefix utf8b64 a =
mimeDecodeB64 $ encoded_payload utf8b64 a
| otherwise =
a
where
utf8qp = "=?UTF-8?Q?"
utf8b64 = "=?UTF-8?B?"
-- '=?UTF-8?Q?foobar?=' -> 'foobar'
encoded_payload prefix = reverse . drop 2 . reverse . drop (length prefix)
mimeDecodeQP :: String -> String
mimeDecodeQP =
US.decodeString . mimeDecodeQP'
mimeDecodeQP' :: String -> String
mimeDecodeQP' ('=':a:b:rest) =
chr (16 * digitToInt a + digitToInt b)
: mimeDecodeQP' rest
mimeDecodeQP' (h:t) =h : mimeDecodeQP' t
mimeDecodeQP' [] = []
mimeDecodeB64 :: String -> String
mimeDecodeB64 s =
case decode s of
Nothing -> ""
Just a -> US.decode a
-- Encode a String into quoted printable, if needed.
-- eq: =?UTF-8?Q?=aa?=
mimeEncodeQP s =
if any reservedChar s
then "=?UTF-8?Q?" ++ (mimeEncodeQP' $ US.encodeString s) ++ "?="
else s
mimeEncodeQP' :: String -> String
mimeEncodeQP' [] = []
mimeEncodeQP' (h:t) =
let str = if reservedChar h then escape h else [h]
in str ++ mimeEncodeQP' t
where
escape x =
let y = ord x in
[ '=', intToDigit ((y `div` 16) .&. 0xf), intToDigit (y .&. 0xf) ]
-- Char needs escaping?
reservedChar :: Char -> Bool
reservedChar x
-- from space (0x20) till '~' everything is fine. The rest are control chars, or high bit.
| xi >= 0x20 && xi <= 0x7e = False
| otherwise = True
where xi = ord x
|