packages feed

servant-auth-hmac-0.1.0.0: example/server/Test.hs

{-# LANGUAGE RecordWildCards   #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleContexts  #-}

import AuthAPI
import Control.Monad (unless)
import Data.IORef (newIORef, readIORef)
import Data.Aeson (encode, decode)
import Data.Default (def)
import Data.Monoid ((<>))
import Data.WithLocation (WithLocation)
import Data.String.Class (ConvStrictByteString(..))
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Network.Wai (Application)
import Test.Hspec (Spec, hspec, describe, it)
import Test.Hspec.Expectations (expectationFailure)
import Test.Hspec.Wai (WaiExpectation, WaiSession, ResponseMatcher, (<:>))
import Test.Hspec.Wai (request, matchHeaders, matchBody, shouldRespondWith, liftIO, with, get)
import Servant.Server.Experimental.Auth.HMAC
import qualified Data.Map as Map
import Servant (Proxy(..))
import Servant.Server (Context ((:.), EmptyContext), serveWithContext)
import Network.HTTP.Types (Header, methodGet, methodPost)
import Network.HTTP.Types.Header (hWWWAuthenticate, hAuthorization, hContentType)
import Network.Wai.Test (SResponse(..))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BSL
import qualified Data.ByteString.Base64 as Base64 (encode)
import qualified Data.ByteString.Char8 as BSC8
import qualified Data.ByteString.Lazy.Char8 as BSLC8


main :: IO ()
main = hspec spec

spec :: Spec
spec = with (app (def :: AuthHmacSettings)) $ do

  describe "POST /login" $ do
    let username = "mr_foo"

    it "rejects a request with wrong username/password" $ do
      let loginArgs = encode $ LoginArgs {
              laUsername = username
            , laPassword = "password"
            }
      request methodPost "/login" [(hContentType, "application/json")] loginArgs
        `shouldRespondWith` 403

    it "accepts a request with correct username/password" $ do
      let loginArgs = encode $ LoginArgs {
              laUsername = username
            , laPassword = "password1"
            }
      request methodPost "/login" [(hContentType, "application/json")] loginArgs
        `shouldRespondWith` 200


  describe "GET /secret" $ do
    let username = "mr_bar"

    it "rejects a request without authoriaztion header" $
      get ("/secret/" <> username) `shouldRespondWith` 401 {
          matchHeaders = [hWWWAuthenticate <:> "HMAC"]
        , matchBody = Just . BSLC8.pack . show $ NotAuthoirized
        }

    it "rejects a request with incorrect authorization header" $ do
      let s = "nope"
      let r = request methodGet ("/secret/" <> username) [("Authorization", s)] ""
      r `shouldRespondWith` 403 {
          matchBody = Just . BSLC8.pack . show $ BadAuthorizationHeader s
        }

    it "rejects a request without appropriate parameters" $ do
      let r = request methodGet ("/secret/" <> username) [mkAuthHeader "" "" Nothing] ""
      r `shouldRespondWith` 403 {
          matchBody = Just . BSLC8.pack . show $ AuthorizationParameterNotFound "timestamp"
        }

    it "rejects an expired request" $ do
      let hdr = mkAuthHeader "" "" $ Just (posixSecondsToUTCTime 0)
      let r = request methodGet ("/secret/" <> username) [hdr] ""
      r  -: shouldRespondWith' (startsWith "RequestExpired ") :- 403


    it "rejects a request without non-existing token" $ do
      hdr <- liftIO $ mkAuthHeader (BSC8.unpack username) "" . Just <$> getCurrentTime
      let r = request methodGet ("/secret/" <> username)  [hdr] ""
      r `shouldRespondWith` 403 {
          matchBody = Just . BSLC8.pack . show $ TokenNotFound username
        }

    it "rejects a request with wrong signature" $ do
      let loginArgs = encode $ LoginArgs {
              laUsername = BSC8.unpack username
            , laPassword = "letmein"
            }
      _ <- request methodPost "/login" [(hContentType, "application/json")] loginArgs

      hdr <- liftIO $ mkAuthHeader (BSC8.unpack username) "" . Just <$> getCurrentTime
      let r = request methodGet ("/secret/" <> username) [hdr] ""

      r -: shouldRespondWith' (startsWith "IncorrectHash ") :- 403


    it "accepts a request with correct signature" $ do
      let loginArgs = encode $ LoginArgs {
              laUsername = BSC8.unpack username
            , laPassword = "letmein"
            }

      (SResponse {..}) <- request methodPost "/login" [(hContentType, "application/json")] loginArgs

      currentTime <- liftIO $ getCurrentTime

      let hash = Base64.encode $ getRequestHash
            (def::AuthHmacSettings)
            (maybe "" id (decode simpleBody))
            (BSC8.unpack username)
            currentTime
            ("/secret/" <> username)
            "GET"
            []
            ""

      let hdr = mkAuthHeader (BSC8.unpack username) hash (Just currentTime)
      let r = request methodGet ("/secret/" <> username) [hdr] ""

      r -: shouldRespondWith' (startsWith "\"Freedom is Slavery\"") :- 200


mkAuthHeader :: AuthHmacAccount -> BS.ByteString -> Maybe UTCTime -> Header
mkAuthHeader account hash mt = let
  timestampStrings = maybe [] (\timestamp -> [
      ",timestamp=\""
    , BSC8.pack . show $ ((truncate . utcTimeToPOSIXSeconds $ timestamp)::Integer)
    , "\""
    ]) $ mt
  in (hAuthorization, BS.concat $ [
      "HMAC "
    , "hash=\"", hash, "\""
    , ",id=\"", toStrictByteString account, "\""
    ] ++ timestampStrings)


app :: AuthHmacSettings -> IO Application
app authSettings = do
  storage <- newIORef $ Map.empty
  let tokenProvider username = (Map.lookup username) <$> (readIORef storage)

  return $ serveWithContext
    (Proxy :: Proxy AuthAPI)
    ((defaultAuthHandler tokenProvider authSettings) :. EmptyContext)
    (serveAuth storage)


-- TODO https://github.com/hspec/hspec-wai/issues/35
infixr 0 -:, :-
data Infix f y = f :- y

(-:) :: a -> Infix (a -> b -> c) b -> c
x -:f:- y = x `f` y

shouldRespondWith' :: WithLocation(
  (BSLC8.ByteString -> Bool)
  -> WaiSession SResponse
  -> ResponseMatcher
  -> WaiExpectation)

shouldRespondWith' bodyMatcher response expectation = do
  r@(SResponse {..}) <- response
  liftIO $ unless (bodyMatcher simpleBody) $ expectationFailure $ unlines [
     "match failed for the body:"
    , BSLC8.unpack simpleBody
    ]
  (return r) `shouldRespondWith` expectation

startsWith :: BSL.ByteString -> BSL.ByteString -> Bool
startsWith prefix s = prefix == BSL.take (BSL.length prefix) s