diff --git a/servant-auth-client/test/Servant/Auth/ClientSpec.hs b/servant-auth-client/test/Servant/Auth/ClientSpec.hs index fdd22ab..64a1ee6 100644 --- a/servant-auth-client/test/Servant/Auth/ClientSpec.hs +++ b/servant-auth-client/test/Servant/Auth/ClientSpec.hs @@ -113,7 +113,7 @@ mgr = unsafePerformIO $ newManager defaultManagerSettings app :: Application app = serveWithContext api ctx server where - ctx = cookieCfg :. jwtCfg :. EmptyContext + ctx = authErrorHandler401 :. cookieCfg :. jwtCfg :. EmptyContext jwtCfg :: JWTSettings jwtCfg = defaultJWTSettings theKey @@ -125,9 +125,8 @@ cookieCfg = defaultCookieSettings server :: Server API server = getInt where - getInt :: AuthResult User -> Handler Int - getInt (Authenticated u) = return . length $ name u - getInt _ = throwAll err401 + getInt :: User -> Handler Int + getInt = return . length . name -- }}} diff --git a/servant-auth-server/CHANGELOG.md b/servant-auth-server/CHANGELOG.md index 79d9419..41ab84d 100644 --- a/servant-auth-server/CHANGELOG.md +++ b/servant-auth-server/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [PVP Versioning](https://pvp.haskell.org/). ## [Unreleased] +## Changed + +- #168 Create AuthErrorHandler for using context for errors (now request + handlers don't need to consider authentication failures). This also removes + `throwAll`, which is no longer necessary [@jkarni] + ## [0.4.5.1] - 2020-02-06 ## Changed @@ -16,7 +22,7 @@ and this project adheres to [PVP Versioning](https://pvp.haskell.org/). ## Changed - #144 servant 0.16 support and drop GHC 7.10 support [@domenkozar] -- #148 removed unused constaint in HasServer instance for Auth +- #148 removed unused constaint in HasServer instance for Auth - #154 GHC 8.8 support [@phadej] ### Added diff --git a/servant-auth-server/README.lhs b/servant-auth-server/README.lhs index 99f8b03..a0ead2d 100644 --- a/servant-auth-server/README.lhs +++ b/servant-auth-server/README.lhs @@ -44,18 +44,11 @@ data Auth (auths :: [*]) val What `Auth [Auth1, Auth2] Something :> API` means is that `API` is protected by *either* `Auth1` *or* `Auth2`, and the result of authentication will be of type -`AuthResult Something`, where : +`Something` (which is what your handlers will see). -~~~ haskell -data AuthResult val - = BadPassword - | NoSuchUser - | Authenticated val - | Indefinite -~~~ -Your handlers will get a value of type `AuthResult Something`, and can decide -what to do with it. +If: + ~~~ haskell @@ -79,12 +72,10 @@ type Protected -- | 'Protected' will be protected by 'auths', which we still have to specify. -protected :: Servant.Auth.Server.AuthResult User -> Server Protected --- If we get an "Authenticated v", we can trust the information in v, since --- it was signed by a key we trust. -protected (Servant.Auth.Server.Authenticated user) = return (name user) :<|> return (email user) --- Otherwise, we return a 401. -protected _ = throwAll err401 +protected :: User -> Server Protected +-- We can trust that the User is accurate, since the information was signed by +-- a key we trust +protected user = return (name user) :<|> return (email user) type Unprotected = "login" @@ -105,7 +96,7 @@ server cs jwts = protected :<|> unprotected cs jwts ~~~ The code is common to all authentications. In order to pick one or more specific -authentication methods, all we need to do is provide the expect configuration +authentication methods, we need to do is provide the expected configuration parameters. ## API tokens @@ -121,11 +112,18 @@ mainWithJWT = do -- We generate the key for signing tokens. This would generally be persisted, -- and kept safely myKey <- generateKey - -- Adding some configurations. All authentications require CookieSettings to - -- be in the context. + -- Adding some configurations. This is specific to JWT let jwtCfg = defaultJWTSettings myKey - cfg = defaultCookieSettings :. jwtCfg :. EmptyContext - --- Here we actually make concrete + -- All authentications also require an AuthErrorHandler to be in the context. + -- That determines how to deal with authentications that failed (because + -- secrets were not present, or because they were incorrect, or because they don't + -- work for the specific type). In this case, we user authErrorHandler401, + -- which returns 401 for no secrets, 403 otherwise + authErrHandler = authErrorHandler401 + -- Here we put everything inside the Servant Context. For technical reasons, + -- all authentications require CookieSettings to be in the context + cfg = defaultCookieSettings :. authErrHandler :. jwtCfg :. EmptyContext + -- Here we actually make concrete the auth type api = Proxy :: Proxy (API '[JWT]) _ <- forkIO $ run 7249 $ serveWithContext api cfg (server defaultCookieSettings jwtCfg) @@ -213,7 +211,7 @@ mainWithCookies = do -- Adding some configurations. 'Cookie' requires, in addition to -- CookieSettings, JWTSettings (for signing), so everything is just as before let jwtCfg = defaultJWTSettings myKey - cfg = defaultCookieSettings :. jwtCfg :. EmptyContext + cfg = defaultCookieSettings :. authErrorHandler401 :. jwtCfg :. EmptyContext --- Here is the actual change api = Proxy :: Proxy (API '[Cookie]) run 7249 $ serveWithContext api cfg (server defaultCookieSettings jwtCfg) diff --git a/servant-auth-server/servant-auth-server.cabal b/servant-auth-server/servant-auth-server.cabal index ff38e4d..97aae34 100644 --- a/servant-auth-server/servant-auth-server.cabal +++ b/servant-auth-server/servant-auth-server.cabal @@ -42,10 +42,11 @@ library , entropy >= 0.4.1.3 && < 0.5 , http-types >= 0.12.2 && < 0.13 , jose >= 0.7.0.0 && < 0.9 - , lens >= 4.16.1 && < 4.19 + , lens >= 4.16.1 && < 4.20 , memory >= 0.14.16 && < 0.16 , monad-time >= 0.3.1.0 && < 0.4 , mtl >= 2.2.2 && < 2.3 + , network-uri >= 2.6 && < 2.7 , servant >= 0.13 && < 0.18 , servant-auth == 0.3.* , servant-server >= 0.13 && < 0.18 @@ -67,7 +68,6 @@ library Servant.Auth.Server.Internal.Cookie Servant.Auth.Server.Internal.FormLogin Servant.Auth.Server.Internal.JWT - Servant.Auth.Server.Internal.ThrowAll Servant.Auth.Server.Internal.Types Servant.Auth.Server.SetCookieOrphan default-language: Haskell2010 diff --git a/servant-auth-server/src/Servant/Auth/Server.hs b/servant-auth-server/src/Servant/Auth/Server.hs index 157fad8..ba05e29 100644 --- a/servant-auth-server/src/Servant/Auth/Server.hs +++ b/servant-auth-server/src/Servant/Auth/Server.hs @@ -1,160 +1,167 @@ module Servant.Auth.Server - ( - -- | This package provides implementations for some common authentication - -- methods. Authentication yields a trustworthy (because generated by the - -- server) value of an some arbitrary type: - -- - -- > type MyApi = Protected - -- > - -- > type Protected = Auth '[JWT, Cookie] User :> Get '[JSON] UserAccountDetails - -- > - -- > server :: Server Protected - -- > server (Authenticated usr) = ... -- here we know the client really is - -- > -- who she claims to be - -- > server _ = throwAll err401 - -- - -- Additional configuration happens via 'Context'. - -- - -- == Example for Custom Handler - -- To use a custom 'Servant.Server.Handler' it is necessary to use - -- 'Servant.Server.hoistServerWithContext' instead of - -- 'Servant.Server.hoistServer' and specify the 'Context'. - -- - -- Below is an example of passing 'CookieSettings' and 'JWTSettings' in the - -- 'Context' to create a specialized function equivalent to - -- 'Servant.Server.hoistServer' for an API that includes cookie - -- authentication. - -- - -- > hoistServerWithAuth - -- > :: HasServer api '[CookieSettings, JWTSettings] - -- > => Proxy api - -- > -> (forall x. m x -> n x) - -- > -> ServerT api m - -- > -> ServerT api n - -- > hoistServerWithAuth api = - -- > hoistServerWithContext api (Proxy :: Proxy '[CookieSettings, JWTSettings]) - - ---------------------------------------------------------------------------- - -- * Auth - -- | Basic types - Auth - , AuthResult(..) - , AuthCheck(..) - - ---------------------------------------------------------------------------- - -- * JWT - -- | JSON Web Tokens (JWT) are a compact and secure way of transferring - -- information between parties. In this library, they are signed by the - -- server (or by some other party posessing the relevant key), and used to - -- indicate the bearer's identity or authorization. - -- - -- Arbitrary information can be encoded - just declare instances for the - -- 'FromJWT' and 'ToJWT' classes. Don't go overboard though - be aware that - -- usually you'll be trasmitting this information on each request (and - -- response!). - -- - -- Note that, while the tokens are signed, they are not encrypted. Do not put - -- any information you do not wish the client to know in them! - - -- ** Combinator - -- | Re-exported from 'servant-auth' - , JWT - - -- ** Classes - , FromJWT(..) - , ToJWT(..) - - -- ** Related types - , IsMatch(..) - - -- ** Settings - , JWTSettings(..) - , defaultJWTSettings - - -- ** Create check - , jwtAuthCheck - - - ---------------------------------------------------------------------------- - -- * Cookie - -- | Cookies are also a method of identifying and authenticating a user. They - -- are particular common when the client is a browser - - -- ** Combinator - -- | Re-exported from 'servant-auth' - , Cookie - - -- ** Settings - , CookieSettings(..) - , XsrfCookieSettings(..) - , defaultCookieSettings - , defaultXsrfCookieSettings - , makeSessionCookie - , makeSessionCookieBS - , makeXsrfCookie - , makeCsrfCookie - , makeCookie - , makeCookieBS - , acceptLogin - , clearSession - - - -- ** Related types - , IsSecure(..) - , SameSite(..) - , AreAuths - - ---------------------------------------------------------------------------- - -- * BasicAuth - -- ** Combinator - -- | Re-exported from 'servant-auth' - , BasicAuth - - -- ** Classes - , FromBasicAuthData(..) - - -- ** Settings - , BasicAuthCfg - - -- ** Related types - , BasicAuthData(..) - , IsPasswordCorrect(..) - - -- ** Authentication request - , wwwAuthenticatedErr - - ---------------------------------------------------------------------------- - -- * Utilies - , ThrowAll(throwAll) - , generateKey - , generateSecret - , fromSecret - , writeKey - , readKey - , makeJWT - - -- ** Re-exports - , Default(def) - , SetCookie - ) where - -import Prelude hiding (readFile, writeFile) -import Data.ByteString (ByteString, writeFile, readFile) -import Data.Default.Class (Default (def)) + ( -- | This package provides implementations for some common authentication + -- methods. Authentication yields a trustworthy (because generated by the + -- server) value of an some arbitrary type: + -- + -- > type MyApi = Protected + -- > + -- > type Protected = Auth '[JWT, Cookie] User :> Get '[JSON] UserAccountDetails + -- > + -- > server :: Server Protected + -- > server usr = ... -- here we know the client really is who she claims to be + -- + -- Additional configuration happens via 'Context'. + -- + -- == Example for Custom Handler + -- To use a custom 'Servant.Server.Handler' it is necessary to use + -- 'Servant.Server.hoistServerWithContext' instead of + -- 'Servant.Server.hoistServer' and specify the 'Context'. + -- + -- Below is an example of passing 'CookieSettings' and 'JWTSettings' in the + -- 'Context' to create a specialized function equivalent to + -- 'Servant.Server.hoistServer' for an API that includes cookie + -- authentication. + -- + -- > hoistServerWithAuth + -- > :: HasServer api '[CookieSettings, JWTSettings] + -- > => Proxy api + -- > -> (forall x. m x -> n x) + -- > -> ServerT api m + -- > -> ServerT api n + -- > hoistServerWithAuth api = + -- > hoistServerWithContext api (Proxy :: Proxy '[CookieSettings, JWTSettings]) + + ---------------------------------------------------------------------------- + + -- * Auth + + -- | Basic types + Auth, + AuthResult (..), + AuthCheck (..), + AuthErrorHandler (..), + + -- ** AuthErrorHandler functions + redirectWhenNotLoggedIn, + basicAuthErrorHandler, + authErrorHandler401, + ---------------------------------------------------------------------------- + + -- * JWT + + -- | JSON Web Tokens (JWT) are a compact and secure way of transferring + -- information between parties. In this library, they are signed by the + -- server (or by some other party posessing the relevant key), and used to + -- indicate the bearer's identity or authorization. + -- + -- Arbitrary information can be encoded - just declare instances for the + -- 'FromJWT' and 'ToJWT' classes. Don't go overboard though - be aware that + -- usually you'll be trasmitting this information on each request (and + -- response!). + -- + -- Note that, while the tokens are signed, they are not encrypted. Do not put + -- any information you do not wish the client to know in them! + + -- ** Combinator + + -- | Re-exported from 'servant-auth' + JWT, + + -- ** Classes + FromJWT (..), + ToJWT (..), + + -- ** Related types + IsMatch (..), + + -- ** Settings + JWTSettings (..), + defaultJWTSettings, + + -- ** Create check + jwtAuthCheck, + ---------------------------------------------------------------------------- + + -- * Cookie + + -- | Cookies are also a method of identifying and authenticating a user. They + -- are particular common when the client is a browser + + -- ** Combinator + + -- | Re-exported from 'servant-auth' + Cookie, + + -- ** Settings + CookieSettings (..), + XsrfCookieSettings (..), + defaultCookieSettings, + defaultXsrfCookieSettings, + makeSessionCookie, + makeSessionCookieBS, + makeXsrfCookie, + makeCsrfCookie, + makeCookie, + makeCookieBS, + acceptLogin, + clearSession, + + -- ** Related types + IsSecure (..), + SameSite (..), + AreAuths, + ---------------------------------------------------------------------------- + + -- * BasicAuth + + -- ** Combinator + + -- | Re-exported from 'servant-auth' + BasicAuth, + + -- ** Classes + FromBasicAuthData (..), + + -- ** Settings + BasicAuthCfg, + + -- ** Related types + BasicAuthData (..), + IsPasswordCorrect (..), + + -- ** Authentication request + wwwAuthenticatedErr, + ---------------------------------------------------------------------------- + + -- * Utilies + generateKey, + generateSecret, + fromSecret, + writeKey, + readKey, + makeJWT, + + -- ** Re-exports + Default (def), + SetCookie, + ) +where + +import Crypto.JOSE as Jose +import Data.ByteString (ByteString, readFile, writeFile) +import Data.Default.Class (Default (def)) +import Servant (BasicAuthData (..)) import Servant.Auth import Servant.Auth.JWT -import Servant.Auth.Server.Internal () +import Servant.Auth.Server.Internal () import Servant.Auth.Server.Internal.BasicAuth import Servant.Auth.Server.Internal.Class import Servant.Auth.Server.Internal.ConfigTypes import Servant.Auth.Server.Internal.Cookie import Servant.Auth.Server.Internal.JWT -import Servant.Auth.Server.Internal.ThrowAll import Servant.Auth.Server.Internal.Types - -import Crypto.JOSE as Jose -import Servant (BasicAuthData (..)) -import Web.Cookie (SetCookie) +import Web.Cookie (SetCookie) +import Prelude hiding (readFile, writeFile) -- | Generate a key suitable for use with 'defaultConfig'. generateKey :: IO Jose.JWK diff --git a/servant-auth-server/src/Servant/Auth/Server/Internal.hs b/servant-auth-server/src/Servant/Auth/Server/Internal.hs index 2e825c0..3ce9490 100644 --- a/servant-auth-server/src/Servant/Auth/Server/Internal.hs +++ b/servant-auth-server/src/Servant/Auth/Server/Internal.hs @@ -15,7 +15,6 @@ import Servant.Auth.Server.Internal.AddSetCookie import Servant.Auth.Server.Internal.Class import Servant.Auth.Server.Internal.Cookie import Servant.Auth.Server.Internal.ConfigTypes -import Servant.Auth.Server.Internal.JWT import Servant.Auth.Server.Internal.Types import Servant.Server.Internal (DelayedIO, addAuthCheck, withRequest) @@ -27,8 +26,9 @@ instance ( n ~ 'S ('S 'Z) , ToJWT v , HasContextEntry ctxs CookieSettings , HasContextEntry ctxs JWTSettings + , HasContextEntry ctxs AuthErrorHandler ) => HasServer (Auth auths v :> api) ctxs where - type ServerT (Auth auths v :> api) m = AuthResult v -> ServerT api m + type ServerT (Auth auths v :> api) m = v -> ServerT api m #if MIN_VERSION_servant_server(0,12,0) hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt . s @@ -40,11 +40,12 @@ instance ( n ~ 'S ('S 'Z) (fmap go subserver `addAuthCheck` authCheck) where - authCheck :: DelayedIO (AuthResult v, SetCookieList ('S ('S 'Z))) - authCheck = withRequest $ \req -> liftIO $ do - authResult <- runAuthCheck (runAuths (Proxy :: Proxy auths) context) req - cookies <- makeCookies authResult - return (authResult, cookies) + authCheck :: DelayedIO (v, SetCookieList ('S ('S 'Z))) + authCheck = withRequest $ \req -> do + authResult <- liftIO $ runAuthCheck (runAuths (Proxy :: Proxy auths) context) req + cookies <- liftIO $ makeCookies authResult + authVal <- authErrHandler authResult + return (authVal, cookies) jwtSettings :: JWTSettings jwtSettings = getContextEntry context @@ -52,6 +53,9 @@ instance ( n ~ 'S ('S 'Z) cookieSettings :: CookieSettings cookieSettings = getContextEntry context + authErrHandler :: AuthResult v -> DelayedIO v + authErrHandler = getAuthErrorHandler $ getContextEntry context + makeCookies :: AuthResult v -> IO (SetCookieList ('S ('S 'Z))) makeCookies authResult = do xsrf <- makeXsrfCookie cookieSettings @@ -64,7 +68,7 @@ instance ( n ~ 'S ('S 'Z) Just jwt -> return $ Just jwt `SetCookieCons` SetCookieNil _ -> return $ Nothing `SetCookieCons` SetCookieNil - go :: (AuthResult v -> ServerT api Handler) - -> (AuthResult v, SetCookieList n) + go :: (v -> ServerT api Handler) + -> (v, SetCookieList n) -> ServerT (AddSetCookiesApi n api) Handler - go fn (authResult, cookies) = addSetCookies cookies $ fn authResult + go fn (authVal, cookies) = addSetCookies cookies $ fn authVal diff --git a/servant-auth-server/src/Servant/Auth/Server/Internal/BasicAuth.hs b/servant-auth-server/src/Servant/Auth/Server/Internal/BasicAuth.hs index f35eb6f..aebdb7c 100644 --- a/servant-auth-server/src/Servant/Auth/Server/Internal/BasicAuth.hs +++ b/servant-auth-server/src/Servant/Auth/Server/Internal/BasicAuth.hs @@ -7,9 +7,10 @@ module Servant.Auth.Server.Internal.BasicAuth where import qualified Data.ByteString as BS import Servant (BasicAuthData (..), - ServerError (..), err401) + ServerError (..), err401, err403) import Servant.Server.Internal.BasicAuth (decodeBAHdr, mkBAChallengerHdr) +import Servant.Server.Internal (delayedFailFatal) import Servant.Auth.Server.Internal.Types @@ -19,26 +20,26 @@ import Servant.Auth.Server.Internal.Types wwwAuthenticatedErr :: BS.ByteString -> ServerError wwwAuthenticatedErr realm = err401 { errHeaders = [mkBAChallengerHdr realm] } --- | A type holding the configuration for Basic Authentication. +-- | A type holding the configuration for Basic Authentication. -- It is defined as a type family with no arguments, so that -- it can be instantiated to whatever type you need to -- authenticate your users (use @type instance BasicAuthCfg = ...@). --- +-- -- Note that the instantiation is application-wide, -- i.e. there can be only one instance. -- As a consequence, it should not be instantiated in a library. --- +-- -- Basic Authentication expects an element of type 'BasicAuthCfg' -- to be in the 'Context'; that element is then passed automatically -- to the instance of 'FromBasicAuthData' together with the -- authentication data obtained from the client. --- +-- -- If you do not need a configuration for Basic Authentication, -- you can use just @BasicAuthCfg = ()@, and recall to also -- add @()@ to the 'Context'. --- A basic but more interesting example is to take as 'BasicAuthCfg' +-- A basic but more interesting example is to take as 'BasicAuthCfg' -- a list of authorised username/password pairs: --- +-- -- > deriving instance Eq BasicAuthData -- > type instance BasicAuthCfg = [BasicAuthData] -- > instance FromBasicAuthData User where @@ -57,3 +58,15 @@ basicAuthCheck :: FromBasicAuthData usr => BasicAuthCfg -> AuthCheck usr basicAuthCheck cfg = AuthCheck $ \req -> case decodeBAHdr req of Nothing -> return Indefinite Just baData -> fromBasicAuthData baData cfg + +-- | An AuthErrorHandler that returns a 403 in case of 'BadPassword' or +-- 'NoSuchUser', and returns a 401 with a `WWW-Authenticate` header for the +-- Basic Authentication in case of 'Indefinite' +-- +-- If you're only using Basic Auth, this is the AuthErrorHandler you want. +basicAuthErrorHandler :: BS.ByteString -> AuthErrorHandler +basicAuthErrorHandler realm = AuthErrorHandler $ \result -> case result of + Authenticated a -> pure a + BadPassword -> delayedFailFatal err403 + NoSuchUser -> delayedFailFatal err403 + Indefinite -> delayedFailFatal (wwwAuthenticatedErr realm) diff --git a/servant-auth-server/src/Servant/Auth/Server/Internal/ThrowAll.hs b/servant-auth-server/src/Servant/Auth/Server/Internal/ThrowAll.hs deleted file mode 100644 index 956af6b..0000000 --- a/servant-auth-server/src/Servant/Auth/Server/Internal/ThrowAll.hs +++ /dev/null @@ -1,49 +0,0 @@ -{-# LANGUAGE CPP #-} -{-# LANGUAGE UndecidableInstances #-} -module Servant.Auth.Server.Internal.ThrowAll where - -#if !MIN_VERSION_servant_server(0,16,0) -#define ServerError ServantErr -#endif - -import Control.Monad.Error.Class -import Data.Tagged (Tagged (..)) -import Servant ((:<|>) (..), ServerError(..)) -import Network.HTTP.Types -import Network.Wai - -import qualified Data.ByteString.Char8 as BS - -class ThrowAll a where - -- | 'throwAll' is a convenience function to throw errors across an entire - -- sub-API - -- - -- - -- > throwAll err400 :: Handler a :<|> Handler b :<|> Handler c - -- > == throwError err400 :<|> throwError err400 :<|> err400 - throwAll :: ServerError -> a - -instance (ThrowAll a, ThrowAll b) => ThrowAll (a :<|> b) where - throwAll e = throwAll e :<|> throwAll e - --- Really this shouldn't be necessary - ((->) a) should be an instance of --- MonadError, no? -instance {-# OVERLAPPING #-} ThrowAll b => ThrowAll (a -> b) where - throwAll e = const $ throwAll e - -instance {-# OVERLAPPABLE #-} (MonadError ServerError m) => ThrowAll (m a) where - throwAll = throwError - --- | for @servant <0.11@ -instance {-# OVERLAPPING #-} ThrowAll Application where - throwAll e _req respond - = respond $ responseLBS (mkStatus (errHTTPCode e) (BS.pack $ errReasonPhrase e)) - (errHeaders e) - (errBody e) - --- | for @servant >=0.11@ -instance {-# OVERLAPPING #-} MonadError ServerError m => ThrowAll (Tagged m Application) where - throwAll e = Tagged $ \_req respond -> - respond $ responseLBS (mkStatus (errHTTPCode e) (BS.pack $ errReasonPhrase e)) - (errHeaders e) - (errBody e) diff --git a/servant-auth-server/src/Servant/Auth/Server/Internal/Types.hs b/servant-auth-server/src/Servant/Auth/Server/Internal/Types.hs index 8e9e91f..5480d05 100644 --- a/servant-auth-server/src/Servant/Auth/Server/Internal/Types.hs +++ b/servant-auth-server/src/Servant/Auth/Server/Internal/Types.hs @@ -1,14 +1,22 @@ {-# LANGUAGE CPP #-} module Servant.Auth.Server.Internal.Types where +#if !MIN_VERSION_servant_server(0,16,0) +#define ServerError ServantErr +#endif + import Control.Applicative import Control.Monad.Reader +import qualified Data.ByteString.Char8 as BSC import Control.Monad.Time import Data.Monoid (Monoid (..)) import Data.Semigroup (Semigroup (..)) import Data.Time (getCurrentTime) import GHC.Generics (Generic) import Network.Wai (Request) +import Network.URI (URI, uriToString) +import Servant (ServerError(..), err302, err403, err401) +import Servant.Server.Internal (DelayedIO, delayedFailFatal) import qualified Control.Monad.Fail as Fail @@ -110,3 +118,36 @@ instance Alternative AuthCheck where instance MonadPlus AuthCheck where mzero = mempty mplus = (<>) + +-- * AuthErrorHandler + +-- | How to handle AuthResult failures. +-- +-- Some AuthErrorHandlers are provided by this library for common use cases. +-- But you can define your own as well (which can, for instance, do logging +-- with the authentication result). +newtype AuthErrorHandler = AuthErrorHandler + { getAuthErrorHandler :: forall a. AuthResult a -> DelayedIO a } + +-- | An AuthErrorHandler that returns a 403 in case of 'BadPassword' or +-- 'NoSuchUser', and redirects to the provided page in case of 'Indefinite'. +-- Likely the page will be a login page. +-- +-- Remember that you can use servant's safeLink machinery to produce a 'URI'! +redirectWhenNotLoggedIn :: URI -> AuthErrorHandler +redirectWhenNotLoggedIn redirectUrl = AuthErrorHandler $ \result -> case result of + Authenticated a -> pure a + BadPassword -> delayedFailFatal err403 + NoSuchUser -> delayedFailFatal err403 + Indefinite -> delayedFailFatal err302 + { errHeaders = [ ("Location", BSC.pack $ uriToString id redirectUrl "") ] } + +-- | An AuthErrorHandler that returns a 403 in case of 'BadPassword' or +-- 'NoSuchUser', and a 401 in case of 'Indefinite' +authErrorHandler401 :: AuthErrorHandler +authErrorHandler401 = AuthErrorHandler $ \result -> case result of + Authenticated a -> pure a + BadPassword -> delayedFailFatal err403 + NoSuchUser -> delayedFailFatal err403 + Indefinite -> delayedFailFatal err401 + diff --git a/servant-auth-server/test/Servant/Auth/ServerSpec.hs b/servant-auth-server/test/Servant/Auth/ServerSpec.hs index 75257f3..7162320 100644 --- a/servant-auth-server/test/Servant/Auth/ServerSpec.hs +++ b/servant-auth-server/test/Servant/Auth/ServerSpec.hs @@ -68,7 +68,6 @@ spec = do authSpec cookieAuthSpec jwtAuthSpec - throwAllSpec basicAuthSpec ------------------------------------------------------------------------------ @@ -379,24 +378,6 @@ basicAuthSpec = describe "The BasicAuth combinator" it "fails with no auth header" $ \port -> do get (url port) `shouldHTTPErrorWith` status401 --- }}} ------------------------------------------------------------------------------- --- * ThrowAll {{{ - -throwAllSpec :: Spec -throwAllSpec = describe "throwAll" $ do - - it "works for plain values" $ do - let t :: Either ServerError Int :<|> Either ServerError Bool :<|> Either ServerError String - t = throwAll err401 - t `shouldBe` throwError err401 :<|> throwError err401 :<|> throwError err401 - - it "works for function types" $ property $ \i -> do - let t :: Int -> (Either ServerError Bool :<|> Either ServerError String) - t = throwAll err401 - expected _ = throwError err401 :<|> throwError err401 - t i `shouldBe` expected i - -- }}} ------------------------------------------------------------------------------ -- * API and Server {{{ @@ -460,29 +441,30 @@ instance FromBasicAuthData User where -- have to add it type instance BasicAuthCfg = JWK -appWithCookie :: AreAuths auths '[CookieSettings, JWTSettings, JWK] User +appWithCookie :: AreAuths auths '[CookieSettings, AuthErrorHandler, JWTSettings, JWK] User => Proxy (API auths) -> CookieSettings -> Application appWithCookie api ccfg = serveWithContext api ctx $ server ccfg where - ctx = ccfg :. jwtCfg :. theKey :. EmptyContext + ctx = ccfg + :. authErrorHandler401 + :. jwtCfg + :. theKey + :. EmptyContext -- | Takes a proxy parameter indicating which authentication systems to enable. -app :: AreAuths auths '[CookieSettings, JWTSettings, JWK] User +app :: AreAuths auths '[CookieSettings, AuthErrorHandler, JWTSettings, JWK] User => Proxy (API auths) -> Application app api = appWithCookie api cookieCfg server :: CookieSettings -> Server (API auths) server ccfg = - (\authResult -> case authResult of - Authenticated usr -> getInt usr - :<|> postInt usr - :<|> getHeaderInt + (\user -> getInt user + :<|> postInt user + :<|> getHeaderInt #if MIN_VERSION_servant_server(0,15,0) - :<|> return (S.source ["bytestring"]) + :<|> return (S.source ["bytestring"]) #endif - :<|> raw - Indefinite -> throwAll err401 - _ -> throwAll err403 + :<|> raw ) :<|> getLogin :<|> getLogout diff --git a/servant-auth/servant-auth.cabal b/servant-auth/servant-auth.cabal index 6ffa53a..a1e2053 100644 --- a/servant-auth/servant-auth.cabal +++ b/servant-auth/servant-auth.cabal @@ -36,7 +36,7 @@ library base >= 4.9 && < 4.14 , aeson >= 1.3.1.1 && < 1.5 , jose >= 0.7.0.0 && < 0.9 - , lens >= 4.16.1 && < 4.19 + , lens >= 4.16.1 && < 4.20 , servant >= 0.15 && < 0.18 , text >= 1.2.3.0 && < 1.3 , unordered-containers >= 0.2.9.0 && < 0.3 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..ef590a0 --- /dev/null +++ b/shell.nix @@ -0,0 +1,12 @@ +{ pkgs ? import {} +}: + +with pkgs; + +mkShell { + name = "servant-auth"; + buildInputs = [ + ghc + stack zlib + ]; +}