packages feed

servant-auth-hmac-0.1.0.0: src/Servant/Server/Experimental/Auth/HMAC.hs

{-# LANGUAGE FlexibleContexts     #-}
{-# LANGUAGE TypeFamilies         #-}
{-# LANGUAGE DataKinds            #-}
{-# LANGUAGE GADTs                #-}
{-# LANGUAGE ScopedTypeVariables  #-}
{-# LANGUAGE KindSignatures       #-}
{-# LANGUAGE OverloadedStrings    #-}
{-# LANGUAGE RecordWildCards      #-}
{-# LANGUAGE TypeOperators        #-}
{-# LANGUAGE PolyKinds            #-}
{-# LANGUAGE FlexibleContexts     #-}

{-|
  Module:      Servant.Server.Experimental.Auth.HMAC
  Copyright:   (c) 2016 Al Zohali
  License:     BSD3
  Maintainer:  Al Zohali <zohl@fmap.me>
  Stability:   experimental


  = Description
  Authentication via hashed message code (HMAC) based on RFC1945.
-}

module Servant.Server.Experimental.Auth.HMAC (
    AuthHmacAccount
  , AuthHmacToken
  , AuthHmacSettings(..)
  , AuthTokenProvider
  , defaultAuthHandler

  , AuthHmacException(..)
  , parseAuthorization
  , getRequestHash
  ) where

import Control.Applicative
import Control.Exception (Exception)
import Control.Monad (when)
import Control.Monad.Catch (MonadThrow, throwM, catch)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Crypto.Hash (HashAlgorithm(..))
import Crypto.Hash.Algorithms (SHA256)
import Crypto.MAC.HMAC (hmac, HMAC)
import Data.Attoparsec.ByteString
import Data.Attoparsec.ByteString.Char8 (char, stringCI)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy (fromStrict)
import Data.CaseInsensitive (CI(..))
import Data.Default
import Data.List (sort)
import Data.String.Class (ConvStrictByteString(..))
import Data.Time.Clock (UTCTime, getCurrentTime, addUTCTime, NominalDiffTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Data.Typeable
import Network.HTTP.Types.Header (Header, HeaderName, hWWWAuthenticate, hAuthorization)
import Network.HTTP.Types.Method (Method)
import Network.Wai (Request(..), requestHeaders)
import Prelude hiding (takeWhile)
import Servant (throwError)
import Servant.API.Experimental.Auth (AuthProtect)
import Servant.Server (ServantErr(..), err401, err403, errBody, Handler)
import Servant.Server.Experimental.Auth (AuthHandler, AuthServerData, mkAuthHandler)
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as Base64 (encode)
import qualified Data.ByteString.Char8  as BSC8
import qualified Data.CaseInsensitive as CI (mk)


-- | A type family that maps user-defined account type to
--   AuthServerData. This should be instantiated as the following:
-- @
--   type instance AuthHmacAccount = UserDefinedType
-- @
type family AuthHmacAccount

-- | A type family that maps user-defined token type to
--   AuthServerData. This should be instantiated as the following:
-- @
--   type instance AuthHmacToken = UserDefinedType
-- @
type family AuthHmacToken

-- | A function to retrieve token by given account.
type AuthTokenProvider = AuthHmacAccount -> IO (Maybe AuthHmacToken)

type AuthHmacData = (AuthHmacAccount, AuthHmacToken)
type instance AuthServerData (AuthProtect ("hmac-auth")) = AuthHmacData


-- | Options that determine authentication mechanisms.
data AuthHmacSettings where
  AuthHmacSettings :: (HashAlgorithm h) => {
    ahsMaxAge        :: NominalDiffTime
  , ahsRealm         :: Maybe ByteString
  , ahsHashAlgorithm :: Proxy h
  , ahsHeaderFilter  :: HeaderName -> Bool
  } -> AuthHmacSettings


instance Default AuthHmacSettings
  where def = AuthHmacSettings {
      ahsMaxAge = fromIntegral (10 * 60 :: Integer) -- 10 minutes
    , ahsRealm = def
    , ahsHashAlgorithm = Proxy :: Proxy SHA256
    , ahsHeaderFilter = ( == "Content-Type")
    }

-- | The exception is thrown when something goes wrong with this package.
data AuthHmacException
  = NotAuthoirized
    -- ^ Thrown when there is no Authorization header in the request.
  | BadAuthorizationHeader ByteString
    -- ^ Thrown when failed to parse Authorization header. Argument of this
    -- constructor: actual header.
  | AuthorizationParameterNotFound ByteString
    -- ^ Thrown when there is missing mandatory parameter in
    -- Authorization header. Argument of this constructor: missing parameter name.
  | RequestExpired UTCTime UTCTime
    -- ^ Thrown when the request has expired. Arguments of this constructor:
    -- expiration time, actual time
  | TokenNotFound ByteString
    -- ^ Thrown when token provider returns 'Nothing'. Argument of this
    -- constructor: string representation of the account.
  | IncorrectHash ByteString ByteString
    -- ^ Thrown when hash in the header and hash or the request differ.
    -- Arguments of this constructor: expected hash, actual hash.
    deriving (Eq, Show, Typeable)

instance (Exception AuthHmacException)


-- | Extract parameters' values from Authentication header.
parseAuthorization :: (MonadThrow m, ConvStrictByteString AuthHmacAccount)
  => AuthHmacSettings
  -> ByteString
  -> m (ByteString, AuthHmacAccount, UTCTime)

parseAuthorization AuthHmacSettings {..} hdr = either
  (\_ -> throwM $ BadAuthorizationHeader hdr)
  getAuthData
  (parseOnly header hdr) where

    header :: Parser [(ByteString, ByteString)]
    header = do
      _          <- stringCI "HMAC"
      _          <- takeWhile1 isLWS
      authParams <- param `sepBy` (char ',')
      return $ authParams

    isLWS c = (c == 9 || c == 32) -- tab or space
    isNotQuote = (/= 34)          -- any but quote

    token = takeWhile1 (inClass "a-zA-Z_-")

    param = do
      name  <- token
      _     <- (takeWhile isLWS) *> (char '=') *> (takeWhile isLWS)
      value <- (char '"') *> (takeWhile isNotQuote) <* (char '"')
      return (name, value)

    getAuthData :: (MonadThrow m)
      => [(ByteString, ByteString)]
      -> m (ByteString, AuthHmacAccount, UTCTime)

    getAuthData params = do
      let getParam s = maybe (throwM (AuthorizationParameterNotFound s)) return (lookup s params)
      [hash, accountId, timestamp] <- sequence $ map getParam ["hash", "id", "timestamp"]

      return (
          hash
        , fromStrictByteString accountId
        , posixSecondsToUTCTime . fromIntegral $ (read . BSC8.unpack $ timestamp :: Int))

sign :: forall h. HashAlgorithm h
  => Proxy h           -- ^ The hash algorithm to use
  -> ByteString        -- ^ The key
  -> ByteString        -- ^ The message
  -> ByteString
sign Proxy key msg = BA.convert (hmac key msg :: HMAC h)

-- | Generate hash based on request and account data.
getRequestHash :: (ConvStrictByteString AuthHmacAccount, ConvStrictByteString AuthHmacToken)
  => AuthHmacSettings
  -> AuthHmacToken
  -> AuthHmacAccount
  -> UTCTime
  -> ByteString        -- ^ URI
  -> Method
  -> [Header]
  -> ByteString        -- ^ Request Body
  -> ByteString

getRequestHash AuthHmacSettings {..} key account timestamp uri method headers body
  = sign ahsHashAlgorithm (toStrictByteString key) $ BS.intercalate "\n" [
      toStrictByteString account
    , BSC8.pack . show $ ((truncate . utcTimeToPOSIXSeconds $ timestamp)::Integer)
    , uri
    , method
    , normalizeHeaders $ filter (\(name, _) -> ahsHeaderFilter name) headers
    , body
    ] where

  normalizeHeaders = BS.intercalate "\n" . sort . map normalize where
    normalize (name, value) = BS.concat [foldedCase name, value]

-- | HMAC handler
defaultAuthHandler :: (ConvStrictByteString AuthHmacAccount, ConvStrictByteString AuthHmacToken)
  => AuthTokenProvider
  -> AuthHmacSettings
  -> AuthHandler Request AuthHmacData

defaultAuthHandler tokenProvider settings@(AuthHmacSettings {..}) = mkAuthHandler handler where

  handler :: Request -> Handler AuthHmacData
  handler req = catch (handler' req) $ \(ex :: AuthHmacException) -> throwError $ case ex of
    NotAuthoirized -> err401 {
        errHeaders = [(
            hWWWAuthenticate
          , maybe "HMAC" (\realm -> BS.concat ["HMAC realm=\"", realm, "\""]) ahsRealm)]
      , errBody = fromStrict $ BSC8.pack $ show ex
      }
    _ -> err403 {
        errBody = fromStrict $ BSC8.pack $ show ex
      }

  handler' :: (MonadThrow m, MonadIO m) => Request -> m AuthHmacData
  handler' req = do

    authHeader <- maybe (throwM NotAuthoirized) return $
      lookup (CI.mk hAuthorization) $ map (\(k, v) -> (CI.mk k, v)) (requestHeaders req)

    (reqHash, account, timestamp) <- parseAuthorization settings $ authHeader

    currentTime <- liftIO getCurrentTime

    let expirationTime = addUTCTime ahsMaxAge timestamp
    when (expirationTime < currentTime) $ throwM (RequestExpired expirationTime currentTime)

    token <- (liftIO $ tokenProvider account)
      >>= maybe (throwM (TokenNotFound (toStrictByteString account))) return

    reqHash' <- liftIO $ Base64.encode <$> getRequestHash
      settings
      token
      account
      timestamp
      (rawPathInfo req)
      (requestMethod req)
      (requestHeaders req)
      <$> (requestBody req)

    when (reqHash /= reqHash') $ throwM (IncorrectHash reqHash reqHash')

    return (account, token)