packages feed

yesod-auth-basic-0.2.0.0: Yesod/Auth/Http/Basic.hs

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

-- | HTTP Basic authentication for Yesod. Use HTTPS.
--
-- Import this module and set @maybeAuthId@ in your existing @YesodAuth@
-- instance, where @AuthId App = Text@:
--
-- @
-- maybeAuthId = defaultMaybeBasicAuthId checkCredentials defaultAuthSettings
-- @
--
-- Supply an application-specific credential checker:
--
-- @
-- checkCredentials :: ByteString -> ByteString -> IO Bool
-- checkCredentials username password =
--     pure (username == "user" && password == "secret") -- Example only.
-- @
--
-- The checker receives decoded bytes unchanged. Usernames must be UTF-8;
-- password encoding is up to the checker. Both successful and failed results
-- are cached for the current request, never across HTTP requests.
--
-- Route authorization and HTTP challenges belong to your application:
--
-- @
-- instance Yesod App where
--     authRoute _ = Nothing -- Return 401 instead of redirecting to a login page.
--
--     isAuthorized ProtectedR _ = do
--         user <- maybeAuthId
--         pure $ maybe AuthenticationRequired (const Authorized) user
--     isAuthorized _ _ = pure Authorized
--
--     errorHandler NotAuthenticated = do
--         addHeader "WWW-Authenticate" "Basic realm=\"API\", charset=\"UTF-8\""
--         defaultErrorHandler NotAuthenticated
--     errorHandler err = defaultErrorHandler err
-- @
--
-- This produces a 401 response for missing or invalid credentials on
-- @ProtectedR@. See <https://www.rfc-editor.org/rfc/rfc7617 RFC 7617>.

module Yesod.Auth.Http.Basic
       (
          -- * Authentication
         defaultMaybeBasicAuthId

         -- * Settings
       , AuthSettings
       , authRealm
       , defaultAuthSettings
       ) where

import qualified Data.ByteString        as BS
import qualified Data.Text.Encoding     as T

import Data.ByteString        (ByteString)
import Data.ByteString.Base64 (decode)
import Data.Text              (Text)
import Data.Typeable
import Data.Word8             (toLower, _colon, _space)
import Network.Wai
import Prelude
import Yesod                  hiding (Header)


-- | Reserved authentication settings. HTTP challenges are application-managed.
newtype AuthSettings = AuthSettings {
      authRealm :: Text
      -- ^ Reserved realm value; does not generate a @WWW-Authenticate@ header.
    } deriving (Eq, Show)

-- | Default reserved settings, with @authRealm = "Realm"@.
defaultAuthSettings :: AuthSettings
defaultAuthSettings = AuthSettings { authRealm = "Realm" }

-- | A request-local authentication result, including failed authentication.
newtype CachedBasicAuthId a
  = CachedBasicAuthId { unCached :: Maybe a }
    deriving Typeable


-- | Application-supplied checker for decoded username and password bytes.
type CheckCreds = ByteString
                  -> ByteString
                  -> IO Bool

-- | Look up HTTP Basic credentials and return the verified UTF-8 username.
--
-- Use as @maybeAuthId@ in a @YesodAuth@ instance with @AuthId@ equal to 'Text'.
-- Missing or malformed headers, invalid UTF-8 usernames, and rejected
-- credentials return 'Nothing'. The checker receives the decoded bytes
-- without trimming; passwords may contain colons.
--
-- Both successful and failed results are cached within the current request.
-- This function does not enforce route access or generate a challenge.
defaultMaybeBasicAuthId
  :: MonadHandler m
  => (ByteString -> ByteString -> IO Bool)
  -> AuthSettings
  -> m (Maybe Text)
defaultMaybeBasicAuthId auth cfg =
    cachedAuth $ waiRequest >>= maybeBasicAuthId auth cfg


-- | Cache the authentication result for the current request.
cachedAuth
  :: (MonadHandler m)
  => m (Maybe Text)
  -> m (Maybe Text)
cachedAuth = fmap unCached . cached . fmap CachedBasicAuthId


-- | Decode and check HTTP Basic credentials for one request.
--
-- The scheme is case-insensitive and followed by one or more ASCII spaces.
-- Credentials must be strictly Base64-encoded (RFC 4648); malformed input is
-- rejected before invoking the credential checker, not silently repaired.
--
-- Request-local caching is provided by 'defaultMaybeBasicAuthId'.
maybeBasicAuthId
  :: (MonadHandler m)
  => CheckCreds
  -> AuthSettings
  -> Request
  -> m (Maybe Text)
maybeBasicAuthId checkCreds _ req =
    case authorization of
      Just (strategy, userpass)
        | BS.map toLower strategy == "basic" ->
              authorizeCredentials $ BS.dropWhile (== _space) userpass
        | otherwise -> return Nothing
      _ -> return Nothing
  where
    authorization = BS.break (== _space)
                    <$> lookup "Authorization" (requestHeaders req)
    authorizeCredentials encoded =
      case decode encoded of
        Left _ -> return Nothing
        Right decoded ->
          let (username, password') = BS.break (== _colon) decoded
          in case BS.uncons password' of
              Nothing -> return Nothing
              Just (_,password) -> do
                authorized <- liftIO $ checkCreds username password
                return $ if authorized
                           then either (const Nothing) Just $ T.decodeUtf8' username
                           else Nothing