haskell-servant / servant-auth

160 stars 73 forks source link

How to get cookies to work? #138

Closed seanhess closed 5 years ago

seanhess commented 5 years ago

I've followed the README carefully, and my server successfully sets the cookies on a login. I can also access an authenticated resource with curl using the Authorization: header

However, for cookies to work, shouldn't it work with the Cookie: header? When I test this in the browser, it sends the following header, but I can't access the protected resource (I'm getting Indefinite as my AuthResult).

Cookie: JWT-Cookie=eyJhbGciOiJIUzUxMiJ9.eyJkYXQiOnsiYWNjb3VudElkIjoiMGViOWU5OTAtYTNjNS00MDNhLWE1YTktZjVkZDNhYzJjZjRiIn19.4E4CE1u-2eS-feGjmMM7nZ26RD5o_A0rpyhd2atKrRCVOKyPAqBMP8TX4g9IdNIcxjo61tT_AUrU9RSxsL5PnA; XSRF-TOKEN=Ydjj2LSbIy3sGwVr90HlZgPqaXmyHhbwc2FwpySahaA=

Again, it does work if I use the Authorization header as shown in the README, but the browser doesn't set that header automatically.

What am I missing? Thanks!

domenkozar commented 5 years ago

You'll need to setup XSRF or disable it.

seanhess commented 5 years ago

@domenkozar The server is sending down the XSRF cookie and the browser is sending it back. (See the Cookie header I pasted, above, that’s exactly what the browser is sending).

Is there something else I need to do? If so, what? The readme seems to imply that one must implement something on the client to handle XSRF, but I think it’s already configured correctly

seanhess commented 5 years ago

I get the same issue when running the example exactly as written in the README. Code is below. Tested via Postman. I can login, and it registers the cookies, but I still get 401 Unauthorized when I try to view the protected resources.

The only change is setting the cookie settings to cookieIsSecure = NotSecure so that I can test locally. Could that be the problem? If so, how can you test it?

module AuthTest where

{-# OPTIONS_GHC -fno-warn-unused-binds #-}
{-# OPTIONS_GHC -fno-warn-deprecations #-}

import Control.Concurrent (forkIO)
import Control.Monad (forever)
import Control.Monad.Trans (liftIO)
import Data.Aeson (FromJSON, ToJSON)
import GHC.Generics (Generic)
import Network.Wai.Handler.Warp (run)
import System.Environment (getArgs)
import Servant
import Servant.Auth.Server
import Servant.Auth.Server.SetCookieOrphan ()

data Auth (auths :: [*]) val

data AuthResult val
  = BadPassword
  | NoSuchUser
  | Authenticated val
  | Indefinite

data User = User { name :: String, email :: String }
   deriving (Eq, Show, Read, Generic)

instance ToJSON User
instance ToJWT User
instance FromJSON User
instance FromJWT User

data Login = Login { username :: String, password :: String }
   deriving (Eq, Show, Read, Generic)

instance ToJSON Login
instance FromJSON Login

type Protected
   = "name" :> Get '[JSON] String
 :<|> "email" :> Get '[JSON] String

-- | '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

type Unprotected =
 "login"
     :> ReqBody '[JSON] Login
     :> PostNoContent '[JSON] (Headers '[ Header "Set-Cookie" SetCookie
                                        , Header "Set-Cookie" SetCookie]
                                       NoContent)
  -- :<|> Raw

unprotected :: CookieSettings -> JWTSettings -> Server Unprotected
unprotected cs jwts = checkCreds cs jwts -- :<|> serveDirectory "example/static"

type API auths = (Servant.Auth.Server.Auth auths User :> Protected) :<|> Unprotected

server :: CookieSettings -> JWTSettings -> Server (API auths)
server cs jwts = protected :<|> unprotected cs jwts

-- In main, we fork the server, and allow new tokens to be created in the
-- command line for the specified user name and email.
mainWithJWT :: IO ()
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.
  let jwtCfg = defaultJWTSettings myKey
      cfg = defaultCookieSettings :. jwtCfg :. EmptyContext
      --- Here we actually make concrete
      api = Proxy :: Proxy (API '[JWT])
  _ <- forkIO $ run 7249 $ serveWithContext api cfg (server defaultCookieSettings jwtCfg)

  putStrLn "Started server on localhost:7249"
  putStrLn "Enter name and email separated by a space for a new token"

  forever $ do
     xs <- words <$> getLine
     case xs of
       [name', email'] -> do
         etoken <- makeJWT (User name' email') jwtCfg Nothing
         case etoken of
           Left e -> putStrLn $ "Error generating token:t" ++ show e
           Right v -> putStrLn $ "New token:\t" ++ show v
       _ -> putStrLn "Expecting a name and email separated by spaces"

mainWithCookies :: IO ()
mainWithCookies = do
  -- We *also* need a key to sign the cookies
  myKey <- generateKey
  -- Adding some configurations. 'Cookie' requires, in addition to
  -- CookieSettings, JWTSettings (for signing), so everything is just as before
  let jwtCfg = defaultJWTSettings myKey
      cokCfg = defaultCookieSettings { cookieIsSecure = NotSecure }
      cfg = cokCfg :. jwtCfg :. EmptyContext
      --- Here is the actual change
      api = Proxy :: Proxy (API '[Cookie])
  run 7249 $ serveWithContext api cfg (server cokCfg jwtCfg)

-- Here is the login handler
checkCreds :: CookieSettings
           -> JWTSettings
           -> Login
           -> Handler (Headers '[ Header "Set-Cookie" SetCookie
                                , Header "Set-Cookie" SetCookie]
                               NoContent)
checkCreds cookieSettings jwtSettings (Login "Ali Baba" "Open Sesame") = do
   -- Usually you would ask a database for the user info. This is just a
   -- regular servant handler, so you can follow your normal database access
   -- patterns (including using 'enter').
   let usr = User "Ali Baba" "ali@email.com"
   mApplyCookies <- liftIO $ acceptLogin cookieSettings jwtSettings usr
   case mApplyCookies of
     Nothing           -> throwError err401
     Just applyCookies -> return $ applyCookies NoContent
checkCreds _ _ _ = throwError err401

main :: IO ()
main = do
  args <- getArgs
  let usage = "Usage: readme (JWT|Cookie)"
  case args of
    ["JWT"] -> mainWithJWT
    ["Cookie"] -> mainWithCookies
    e -> putStrLn $ "Arguments: \"" ++ unwords e ++ "\" not understood\n" ++ usage

Run via

$ stack ghci
> mainWithCookies
domenkozar commented 5 years ago

How are you requesting protected endpoint? From README: XSRF protection works by requiring that there be a header of the same value as a distinguished cookie that is set by the server on each request

domenkozar commented 5 years ago

My recommendation would be to turn off CSRF and rely https://caniuse.com/#search=samesite instead (which is already set by default in servant-auth)

seanhess commented 5 years ago

Thanks @domenkozar. I misunderstood how XSRF works. I thought it was automatic, but as you said, one must set the X-XSRF-TOKEN header. Since my browser was including the XSRF token in the cookie header I thought I was doing it correctly.

The docs are fairly clear, I read too quickly. I think they might be improved by adding a curl example for cookies. (It would have been easier to see I needed to set that header, and to compare my requests to the one generated by curl)

Also, your advice says you shouldn't disable XSRF if you use Javascript, but my reading suggests that the Same-Site cookie setting is secure for most applications, and supersedes XSRF tokens. It seems like leaving XSRF tokens disabled and same-site on might be a good default setting. I'm not an expert though.

seanhess commented 5 years ago

Other documentation suggestions: the way you're using a literate haskell file as your readme is cool, but it doesn't display on mobile. Even worse, it doesn't get picked up by hackage, so it's hard to discover. Compare https://hackage.haskell.org/package/servant-auth-server to http://hackage.haskell.org/package/selda. You can see the README on the hackage page in the latter.

Thanks for your hard work!

7puns commented 5 years ago

I have a similar problem as described by @seanhess. I am able to access the protected resource using curl but not with the browser. FYI I have disabled XSRF and using NotSecure in the defaultCookieSettings for my tests. I suppose that is all that is required for the browser to access the protected resource. In my case this is not working. I get the AuthResult Indefinite My question is, do I have to set the header in the browser (client) i.e. "Authorization: Bearer <token>" for this to work (to access protected resource)? P.S. <token> refers to the JWT-Cookie in Set-Cookie header.

A copy of the request and response headers are shown below: image

7puns commented 5 years ago

Is there a way to access the request headers from the server side? I use Wai's RequestLogger to log requests in development. However, I do not see cookie headers in these logs.

alpmestan commented 5 years ago

You can also temporarily add Header "header name" Text to one of your handlers and print it from there. But using a request logger should work, or even a hacky little Middleware like debug :: Middleware ; debug app req resp = do { putStrLn "Request headers:" ; print (requestHeaders req) ; app req resp } that you can apply to your application by just doing run <port> $ debug (serve api yourServer). Or something like wireshark, if needs be...

7puns commented 5 years ago

I have been able to solve the issue of logging the request cookie headers by using a custom middleware. Through the middleware the request object which contains the request headers becomes available. I am able to access all the headers. @alpmestan thanks for your suggestions.