Search code examples
authenticationhaskellfunctional-programmingmiddlewarescotty

How to add basic auth to Scotty middleware?


I'm currently making a Scotty API and I couldn't find any examples of basicAuth implementations (Wai Middleware HttpAuth).

Specifically, I want to add basic auth headers (user, pass) to SOME of my endpoints (namely, ones that start with "admin"). I have everything set up, but I can't seem to make the differentiation as to which endpoints require auth and which ones don't. I know I need to use something like this, but it uses Yesod, and I wasn't able to translate it to Scotty.

So far, I have this:

routes :: (App r m) => ScottyT LText m ()
routes = do
  -- middlewares
  middleware $ cors $ const $ Just simpleCorsResourcePolicy
    { corsRequestHeaders = ["Authorization", "Content-Type"]
    , corsMethods = "PUT":"DELETE":simpleMethods
    }
    
  middleware $ basicAuth 
      (\u p -> return $ u == "username" && p == "password") 
      "My Realm" 
  
  -- errors
  defaultHandler $ \str -> do
    status status500
    json str

  -- feature routes
  ItemController.routes
  ItemController.adminRoutes
  
  -- health
  get "/api/health" $
    json True

But it adds authentication to all my requests. I only need it in some of them.

Thank you so much!


Solution

  • You can use the authIsProtected field of the AuthSettings to define a function Request -> IO Bool that determines if a particular (Wai) Request is subject to authorization by basic authentication. In particular, you can inspect the URL path components and make a determination that way.

    Unfortunately, this means that the check for authorization is completely separated from the Scotty routing. This works fine in your case but can make fine-grained control of authorization by Scotty route difficult.

    Anyway, the AuthSettings are the overloaded "My Realm" string in your source, and according to the documentation, the recommended way of defining the settings is to use the overloaded string to write something like:

    authSettings :: AuthSettings
    authSettings = "My Realm" { authIsProtected = needsAuth }
    

    That looks pretty horrible, but anyway, the needsAuth function will have signature:

    needsAuth :: Request -> IO Bool
    

    so it can inspect the Wai Request and render a decision in IO on whether or not the page needs basic authentication first. Calling pathInfo on the Request gives you a list of path components (no hostname and no query parameters). So, for your needs, the following should work:

    needsAuth req = return $ case pathInfo req of
      "admin":_ -> True   -- all admin pages need authentication
      _         -> False  -- everything else is public
    

    Note that these are the parsed non-query path components, so /admin and /admin/ and /admin/whatever and even /admin/?q=hello are protected, but obviously /administrator/... is not.

    A full example:

    {-# LANGUAGE OverloadedStrings #-}
    
    import Web.Scotty
    import Network.Wai.Middleware.HttpAuth
    import Data.Text ()   -- needed for "admin" overloaded string in case
    import Network.Wai (Request, pathInfo)
    
    authSettings :: AuthSettings
    authSettings = "My Realm" { authIsProtected = needsAuth }
    
    needsAuth :: Request -> IO Bool
    needsAuth req = return $ case pathInfo req of
      "admin":_ -> True   -- all admin pages need authentication
      _         -> False  -- everything else is public
    
    main = scotty 3000 $ do
      middleware $ basicAuth (\u p -> return $ u == "username" && p == "password") authSettings
      get "/admin/deletedb" $ do
        html "<h1>Password database erased!</h1>"
      get "/" $ do
        html "<h1>Homepage</h1><p>Please don't <a href=/admin/deletedb>Delete the passwords</a>"