packages feed

yesod-auth-basic-0.2.0.0: test/YesodAuthHttpBasicSpec.hs

{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# OPTIONS_GHC -fno-warn-orphans  #-}

module YesodAuthHttpBasicSpec where

import           Control.Monad (forM_)
import           Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as Base64
import           Data.IORef
import qualified Data.Text.Encoding as T
import           Test.Hspec
import           Yesod       hiding (get)
import           Yesod.Auth.Http.Basic
import           Yesod.Test


spec :: SpecWith ()
spec = describe "Yesod HTTP Basic Authentication" $ do
  calls <- runIO $ newIORef 0
  yesodSpec (app calls) $ do
    forM_ validHeaders $ \(label, header, username) ->
      yit label $ do
        liftIO $ writeIORef calls 0
        requestWith $ Just header
        statusIs 200
        bodyEquals username
        -- The handler asks twice, but the checker runs only once per request.
        liftIO $ readIORef calls `shouldReturn` 1

    forM_ malformedHeaders $ \(label, header) ->
      yit ("rejects " <> label <> " before checking credentials") $ do
        liftIO $ writeIORef calls 0
        requestWith $ Just header
        statusIs 401
        liftIO $ readIORef calls `shouldReturn` 0

    yit "rejects missing Authorization without checking credentials" $ do
      liftIO $ writeIORef calls 0
      requestWith Nothing
      statusIs 401
      liftIO $ readIORef calls `shouldReturn` 0

    yit "caches a failed credential check within the request" $ do
      liftIO $ writeIORef calls 0
      requestWith $ Just $ basic "user:wrong"
      statusIs 401
      liftIO $ readIORef calls `shouldReturn` 1

    yit "rejects an invalid UTF-8 username even when the checker accepts its bytes" $ do
      liftIO $ writeIORef calls 0
      requestWith $ Just $ basic $ BS.singleton 255 <> ":secret"
      statusIs 401
      liftIO $ readIORef calls `shouldReturn` 1

    yit "does not reuse authentication between HTTP requests" $ do
      liftIO $ writeIORef calls 0
      requestWith $ Just $ basic "user:secret"
      statusIs 200
      requestWith $ Just $ basic "user:secret"
      statusIs 200
      liftIO $ readIORef calls `shouldReturn` 2

validHeaders :: [(String, ByteString, String)]
validHeaders =
  [ ("accepts standard Basic Auth", basic "user:secret", "user")
  , ("accepts a case-insensitive scheme", "bAsIc " <> payload, "user")
  , ("accepts multiple ASCII separator spaces", "Basic   " <> payload, "user")
  , ("preserves colons in the password and accepts Base64 needing no padding", basic "user:se:cret", "user")
  , ("preserves whitespace inside decoded credentials", basic " user : secret ", " user ")
  , ("allows the checker to accept an empty password", basic "user:", "user")
  , ("accepts a UTF-8 username", basic (utf8Username <> ":secret"), "josé")
  ]

malformedHeaders :: [(String, ByteString)]
malformedHeaders =
  [ ("double-quoted tokens", "Basic \"" <> payload <> "\"")
  , ("single-quoted tokens", "Basic '" <> payload <> "'")
  , ("punctuation inside Base64", "Basic " <> BS.take 4 payload <> "!" <> BS.drop 4 payload)
  , ("whitespace inside Base64", "Basic " <> BS.take 4 payload <> " " <> BS.drop 4 payload)
  , ("a tab separator", "Basic\t" <> payload)
  , ("a tab after a separator space", "Basic \t" <> payload)
  , ("a nonbreaking-space separator", "Basic\160" <> payload)
  , ("missing required padding", "Basic " <> BS.takeWhile (/= 61) payload)
  , ("excess padding", "Basic " <> payload <> "=")
  , ("trailing junk after padding", "Basic " <> payload <> "ignored")
  , ("a trailing quote after padding", "Basic " <> payload <> "\"")
  , ("an impossible Base64 length", basic "user:se:cret" <> "A")
  , ("credentials without a colon", basic "user")
  , ("an unsupported scheme", "Bearer " <> payload)
  , ("a missing separator", "Basic" <> payload)
  , ("an empty payload", "Basic ")
  ]

payload :: ByteString
payload = Base64.encode "user:secret"

basic :: ByteString -> ByteString
basic credentials = "Basic " <> Base64.encode credentials

utf8Username :: ByteString
utf8Username = T.encodeUtf8 "josé"

requestWith :: Maybe ByteString -> YesodExample LiteApp ()
requestWith header = request $ do
  setMethod "GET"
  setUrl $ LiteAppRoute []
  forM_ header $ \value -> addRequestHeader ("Authorization", value)


instance RenderMessage LiteApp FormMessage where
  renderMessage _ _ = defaultFormMessage


app :: IORef Int -> LiteApp
app calls = liteApp $
  dispatchTo $ do
    first <- defaultMaybeBasicAuthId checkCreds defaultAuthSettings
    second <- defaultMaybeBasicAuthId checkCreds defaultAuthSettings
    case (first, second) of
      (Just username, Just cachedUsername) | username == cachedUsername -> return username
      _ -> notAuthenticated
  where
    checkCreds username password = do
      modifyIORef calls (+ 1)
      return $ (username, password) `elem`
        [ ("user", "secret"), ("user", "se:cret"), (" user ", " secret "), ("user", "")
        , (utf8Username, "secret"), (BS.singleton 255, "secret") ]