Authentication and Authorization

Authentication and authorization are two very related, and yet separate, concepts. While the former deals with identifying a user, the latter determines what a user is allowed to do. Unfortunately, since both terms are often abbreviated as “auth,” the concepts are often conflated.

Yesod provides built-in support for a number of third-party authentication systems, such as OpenID, BrowserID and OAuth. These are systems where your application trusts some external system for validating a user’s credentials. Additionally, there is support for more commonly used username/password and email/password systems. The former route ensures simplicity for users (no new passwords to remember) and implementors (no need to deal with an entire security architecture), while the latter gives the developer more control.

On the authorization side, we are able to take advantage of REST and type-safe URLs to create simple, declarative systems. Additionally, since all authorization code is written in Haskell, you have the full flexibility of the language at your disposal.

This chapter will cover how to set up an “auth” solution in Yesod and discuss some trade-offs in the different authentication options.

Overview

The yesod-auth package provides a unified interface for a number of different authentication plugins. The only real requirement for these backends is that they identify a user based on some unique string. In OpenID, for instance, this would be the actual OpenID value. In BrowserID, it’s the email address. For HashDB (which uses a database of hashed passwords), it’s the username.

Each authentication plugin provides its own system for logging in, whether it be via passing tokens with an external site or an email/password form. After a successful login, the plugin sets a value in the user’s session to indicate his/her AuthId. This AuthId is usually a Persistent ID from a table used for keeping track of users.

There are a few functions available for querying a user’s AuthId, most commonly maybeAuthId, requireAuthId, maybeAuth and requireAuth. The “require” versions will redirect to a login page if the user is not logged in, while the second set of functions (the ones not ending in Id) give both the table ID and entity value.

Since all of the storage of AuthId is built on top of sessions, all of the rules from there apply. In particular, the data is stored in an encrypted, HMACed client cookie, which automatically times out after a certain configurable period of inactivity. Additionally, since there is no server-side component to sessions, logging out simply deletes the data from the session cookie; if a user reuses an older cookie value, the session will still be valid.

You can replace the default client-side sessions with server side sessions, to provide a forced logout capability, if this is desired. Also, if you wish to secure your sessions from man in the middle (MITM) attacks, you should serve your site over SSL and harden your sessions via sslOnlySessions and sslOnlyMiddleware, as described in the sessions chapter.

On the flip side, authorization is handled by a few methods inside the Yesod typeclass. For every request, these methods are run to determine if access should be allowed, denied, or if the user needs to be authenticated. By default, these methods allow access for every request. Alternatively, you can implement authorization in a more ad-hoc way by adding calls to requireAuth and the like within individual handler functions, though this undermines many of the benefits of a declarative authorization system.

Authenticate Me

Let’s jump right in with an example of authentication. For the Google OAuth authentication to work you should follow these steps:

  1. Read on Google Developers Help how to obtain OAuth 2.0 credentials such as a client ID and client secret that are known to both Google and your application.

  2. Set Authorized redirect URIs to http://localhost:3000/auth/page/googleemail2/complete.

  3. Enable Google+ API and Contacts API.

  4. Once you have the clientId and secretId, replace them in the code below.

  1. {-# LANGUAGE MultiParamTypeClasses #-}
  2. {-# LANGUAGE OverloadedStrings #-}
  3. {-# LANGUAGE QuasiQuotes #-}
  4. {-# LANGUAGE TemplateHaskell #-}
  5. {-# LANGUAGE TypeFamilies #-}
  6. import Data.Default (def)
  7. import Data.Text (Text)
  8. import Network.HTTP.Client.Conduit (Manager, newManager)
  9. import Yesod
  10. import Yesod.Auth
  11. import Yesod.Auth.GoogleEmail2
  12. -- Replace with Google client ID.
  13. clientId :: Text
  14. clientId = ""
  15. -- Replace with Google secret ID.
  16. clientSecret :: Text
  17. clientSecret = ""
  18. data App = App
  19. { httpManager :: Manager
  20. }
  21. mkYesod "App" [parseRoutes|
  22. / HomeR GET
  23. /auth AuthR Auth getAuth
  24. |]
  25. instance Yesod App where
  26. -- Note: In order to log in with BrowserID, you must correctly
  27. -- set your hostname here.
  28. approot = ApprootStatic "http://localhost:3000"
  29. instance YesodAuth App where
  30. type AuthId App = Text
  31. authenticate = return . Authenticated . credsIdent
  32. loginDest _ = HomeR
  33. logoutDest _ = HomeR
  34. authPlugins _ = [ authGoogleEmail clientId clientSecret ]
  35. -- The default maybeAuthId assumes a Persistent database. We're going for a
  36. -- simpler AuthId, so we'll just do a direct lookup in the session.
  37. maybeAuthId = lookupSession "_ID"
  38. instance RenderMessage App FormMessage where
  39. renderMessage _ _ = defaultFormMessage
  40. getHomeR :: Handler Html
  41. getHomeR = do
  42. maid <- maybeAuthId
  43. defaultLayout
  44. [whamlet|
  45. <p>Your current auth ID: #{show maid}
  46. $maybe _ <- maid
  47. <p>
  48. <a href=@{AuthR LogoutR}>Logout
  49. $nothing
  50. <p>
  51. <a href=@{AuthR LoginR}>Go to the login page
  52. |]
  53. main :: IO ()
  54. main = do
  55. man <- newManager
  56. warp 3000 $ App man

We’ll start with the route declarations. First, we declare our standard HomeR route, and then we set up the authentication subsite. Remember that a subsite needs four parameters: the path to the subsite, the route name, the subsite name, and a function to get the subsite value. In other words, based on the line:

  1. /auth AuthR Auth getAuth

We need to have getAuth :: MyAuthSite → Auth. While we haven’t written that function ourselves, yesod-auth provides it automatically. With other subsites (like static files), we provide configuration settings in the subsite value, and therefore need to specify the get function. In the auth subsite, we specify these settings in a separate typeclass, YesodAuth.

Why not use the subsite value? There are a number of settings we would like to give for an auth subsite, and doing so from a record type would be inconvenient. Also, since we want to have an AuthId associated type, a typeclass is more natural. And why not use a typeclass for all subsites? It comes with a downside: you can then only have a single instance per site, disallowing serving different sets of static files from different routes. Also, the subsite value works better when we want to load data at app initialization.

So what exactly goes in this YesodAuth instance? There are five required declarations:

  • AuthId is an associated type. This is the value yesod-auth will give you when you ask if a user is logged in (via maybeAuthId or requireAuthId). In our case, we’re simply using Text, to store the raw identifier- email address in our case, as we’ll soon see.

  • authenticate gets the actual AuthId from the Creds (credentials) data type. This type has three pieces of information: the authentication backend used (googleemail in our case), the actual identifier, and an associated list of arbitrary extra information. Each backend provides different extra information; see their docs for more information.

  • loginDest gives the route to redirect to after a successful login.

  • Likewise, logoutDest gives the route to redirect to after a logout.

  • authPlugins is a list of individual authentication backends to use. In our example, we’re using Google OAuth, which authenticates a user using their Google account.

In addition to these five methods, there are other methods available to control other behavior of the authentication system, such as what the login page looks like. For more information, please see the API documentation.

In our HomeR handler, we have some simple links to the login and logout pages, depending on whether or not the user is logged in. Notice how we construct these subsite links: first we give the subsite route name (AuthR), followed by the route within the subsite (LoginR and LogoutR).

Email

For many use cases, third-party authentication of email will be sufficient. Occasionally, you’ll want users to create passwords on your site. The scaffolded site does not include this setup, because:

  • In order to securely accept passwords, you need to be running over SSL. Many users are not serving their sites over SSL.

  • While the email backend properly salts and hashes passwords, a compromised database could still be problematic. Again, we make no assumptions that Yesod users are following secure deployment practices.

  • You need to have a working system for sending email. Many web servers these days are not equipped to deal with all of the spam protection measures used by mail servers.

The example below will use the system’s built-in sendmail executable. If you would like to avoid the hassle of dealing with an email server yourself, you can use Amazon SES. There is a package called mime-mail-ses which provides a drop-in replacement for the sendmail code used below. This is the approach I generally recommend, and what I use on most of my sites, including FP Haskell Center and Haskellers.com.

But assuming you are able to meet these demands, and you want to have a separate password login specifically for your site, Yesod offers a built-in backend. It requires quite a bit of code to set up, since it needs to store passwords securely in the database and send a number of different emails to users (verify account, password retrieval, etc.).

Let’s have a look at a site that provides email authentication, storing passwords in a Persistent SQLite database.

Even if you don’t have an email server, for debugging purposes the verification link is printed in the console.

  1. {-# LANGUAGE DeriveDataTypeable #-}
  2. {-# LANGUAGE FlexibleContexts #-}
  3. {-# LANGUAGE GADTs #-}
  4. {-# LANGUAGE GeneralizedNewtypeDeriving #-}
  5. {-# LANGUAGE MultiParamTypeClasses #-}
  6. {-# LANGUAGE OverloadedStrings #-}
  7. {-# LANGUAGE QuasiQuotes #-}
  8. {-# LANGUAGE TemplateHaskell #-}
  9. {-# LANGUAGE TypeFamilies #-}
  10. import Control.Monad (join)
  11. import Control.Monad.Logger (runNoLoggingT)
  12. import Data.Maybe (isJust)
  13. import Data.Text (Text, unpack)
  14. import qualified Data.Text.Lazy.Encoding
  15. import Data.Typeable (Typeable)
  16. import Database.Persist.Sqlite
  17. import Database.Persist.TH
  18. import Network.Mail.Mime
  19. import Text.Blaze.Html.Renderer.Utf8 (renderHtml)
  20. import Text.Hamlet (shamlet)
  21. import Text.Shakespeare.Text (stext)
  22. import Yesod
  23. import Yesod.Auth
  24. import Yesod.Auth.Email
  25. share [mkPersist sqlSettings { mpsGeneric = False }, mkMigrate "migrateAll"] [persistLowerCase|
  26. User
  27. email Text
  28. password Text Maybe -- Password may not be set yet
  29. verkey Text Maybe -- Used for resetting passwords
  30. verified Bool
  31. UniqueUser email
  32. deriving Typeable
  33. |]
  34. data App = App SqlBackend
  35. mkYesod "App" [parseRoutes|
  36. / HomeR GET
  37. /auth AuthR Auth getAuth
  38. |]
  39. instance Yesod App where
  40. -- Emails will include links, so be sure to include an approot so that
  41. -- the links are valid!
  42. approot = ApprootStatic "http://localhost:3000"
  43. yesodMiddleware = defaultCsrfMiddleware . defaultYesodMiddleware
  44. instance RenderMessage App FormMessage where
  45. renderMessage _ _ = defaultFormMessage
  46. -- Set up Persistent
  47. instance YesodPersist App where
  48. type YesodPersistBackend App = SqlBackend
  49. runDB f = do
  50. App conn <- getYesod
  51. runSqlConn f conn
  52. instance YesodAuth App where
  53. type AuthId App = UserId
  54. loginDest _ = HomeR
  55. logoutDest _ = HomeR
  56. authPlugins _ = [authEmail]
  57. -- Need to find the UserId for the given email address.
  58. authenticate creds = liftHandler $ runDB $ do
  59. x <- insertBy $ User (credsIdent creds) Nothing Nothing False
  60. return $ Authenticated $
  61. case x of
  62. Left (Entity userid _) -> userid -- existing user
  63. Right userid -> userid -- newly added user
  64. instance YesodAuthPersist App
  65. -- Here's all of the email-specific code
  66. instance YesodAuthEmail App where
  67. type AuthEmailId App = UserId
  68. afterPasswordRoute _ = HomeR
  69. addUnverified email verkey =
  70. liftHandler $ runDB $ insert $ User email Nothing (Just verkey) False
  71. sendVerifyEmail email _ verurl = do
  72. -- Print out to the console the verification email, for easier
  73. -- debugging.
  74. liftIO $ putStrLn $ "Copy/ Paste this URL in your browser:" ++ unpack verurl
  75. -- Send email.
  76. liftIO $ renderSendMail (emptyMail $ Address Nothing "noreply")
  77. { mailTo = [Address Nothing email]
  78. , mailHeaders =
  79. [ ("Subject", "Verify your email address")
  80. ]
  81. , mailParts = [[textPart, htmlPart]]
  82. }
  83. where
  84. textPart = Part
  85. { partType = "text/plain; charset=utf-8"
  86. , partEncoding = None
  87. , partDisposition = DefaultDisposition
  88. , partContent = PartContent $ Data.Text.Lazy.Encoding.encodeUtf8
  89. [stext|
  90. Please confirm your email address by clicking on the link below.
  91. #{verurl}
  92. Thank you
  93. |]
  94. , partHeaders = []
  95. }
  96. htmlPart = Part
  97. { partType = "text/html; charset=utf-8"
  98. , partEncoding = None
  99. , partDisposition = DefaultDisposition
  100. , partContent = PartContent $ renderHtml
  101. [shamlet|
  102. <p>Please confirm your email address by clicking on the link below.
  103. <p>
  104. <a href=#{verurl}>#{verurl}
  105. <p>Thank you
  106. |]
  107. , partHeaders = []
  108. }
  109. getVerifyKey = liftHandler . runDB . fmap (join . fmap userVerkey) . get
  110. setVerifyKey uid key = liftHandler $ runDB $ update uid [UserVerkey =. Just key]
  111. verifyAccount uid = liftHandler $ runDB $ do
  112. mu <- get uid
  113. case mu of
  114. Nothing -> return Nothing
  115. Just u -> do
  116. update uid [UserVerified =. True, UserVerkey =. Nothing]
  117. return $ Just uid
  118. getPassword = liftHandler . runDB . fmap (join . fmap userPassword) . get
  119. setPassword uid pass = liftHandler . runDB $ update uid [UserPassword =. Just pass]
  120. getEmailCreds email = liftHandler $ runDB $ do
  121. mu <- getBy $ UniqueUser email
  122. case mu of
  123. Nothing -> return Nothing
  124. Just (Entity uid u) -> return $ Just EmailCreds
  125. { emailCredsId = uid
  126. , emailCredsAuthId = Just uid
  127. , emailCredsStatus = isJust $ userPassword u
  128. , emailCredsVerkey = userVerkey u
  129. , emailCredsEmail = email
  130. }
  131. getEmail = liftHandler . runDB . fmap (fmap userEmail) . get
  132. getHomeR :: Handler Html
  133. getHomeR = do
  134. maid <- maybeAuthId
  135. defaultLayout
  136. [whamlet|
  137. <p>Your current auth ID: #{show maid}
  138. $maybe _ <- maid
  139. <p>
  140. <a href=@{AuthR LogoutR}>Logout
  141. $nothing
  142. <p>
  143. <a href=@{AuthR LoginR}>Go to the login page
  144. |]
  145. main :: IO ()
  146. main = runNoLoggingT $ withSqliteConn "email.db3" $ \conn -> liftIO $ do
  147. runSqlConn (runMigration migrateAll) conn
  148. warp 3000 $ App conn

Authorization

Once you can authenticate your users, you can use their credentials to authorize requests. Authorization in Yesod is simple and declarative: most of the time, you just need to add the authRoute and isAuthorized methods to your Yesod typeclass instance. Let’s see an example.

  1. {-# LANGUAGE MultiParamTypeClasses #-}
  2. {-# LANGUAGE OverloadedStrings #-}
  3. {-# LANGUAGE QuasiQuotes #-}
  4. {-# LANGUAGE TemplateHaskell #-}
  5. {-# LANGUAGE TypeFamilies #-}
  6. import Data.Default (def)
  7. import Data.Text (Text)
  8. import Network.HTTP.Conduit (Manager, newManager, tlsManagerSettings)
  9. import Yesod
  10. import Yesod.Auth
  11. import Yesod.Auth.Dummy -- just for testing, don't use in real life!!!
  12. data App = App
  13. { httpManager :: Manager
  14. }
  15. mkYesod "App" [parseRoutes|
  16. / HomeR GET POST
  17. /admin AdminR GET
  18. /auth AuthR Auth getAuth
  19. |]
  20. instance Yesod App where
  21. authRoute _ = Just $ AuthR LoginR
  22. -- route name, then a boolean indicating if it's a write request
  23. isAuthorized HomeR True = isAdmin
  24. isAuthorized AdminR _ = isAdmin
  25. -- anyone can access other pages
  26. isAuthorized _ _ = return Authorized
  27. isAdmin = do
  28. mu <- maybeAuthId
  29. return $ case mu of
  30. Nothing -> AuthenticationRequired
  31. Just "admin" -> Authorized
  32. Just _ -> Unauthorized "You must be an admin"
  33. instance YesodAuth App where
  34. type AuthId App = Text
  35. authenticate = return . Authenticated . credsIdent
  36. loginDest _ = HomeR
  37. logoutDest _ = HomeR
  38. authPlugins _ = [authDummy]
  39. maybeAuthId = lookupSession "_ID"
  40. instance RenderMessage App FormMessage where
  41. renderMessage _ _ = defaultFormMessage
  42. getHomeR :: Handler Html
  43. getHomeR = do
  44. maid <- maybeAuthId
  45. defaultLayout
  46. [whamlet|
  47. <p>Note: Log in as "admin" to be an administrator.
  48. <p>Your current auth ID: #{show maid}
  49. $maybe _ <- maid
  50. <p>
  51. <a href=@{AuthR LogoutR}>Logout
  52. <p>
  53. <a href=@{AdminR}>Go to admin page
  54. <form method=post>
  55. Make a change (admins only)
  56. \ #
  57. <input type=submit>
  58. |]
  59. postHomeR :: Handler ()
  60. postHomeR = do
  61. setMessage "You made some change to the page"
  62. redirect HomeR
  63. getAdminR :: Handler Html
  64. getAdminR = defaultLayout
  65. [whamlet|
  66. <p>I guess you're an admin!
  67. <p>
  68. <a href=@{HomeR}>Return to homepage
  69. |]
  70. main :: IO ()
  71. main = do
  72. manager <- newManager tlsManagerSettings
  73. warp 3000 $ App manager

authRoute should be your login page, almost always AuthR LoginR. isAuthorized is a function that takes two parameters: the requested route, and whether or not the request was a “write” request. You can actually change the meaning of what a write request is using the isWriteRequest method, but the out-of-the-box version follows RESTful principles: anything but a GET, HEAD, OPTIONS or TRACE request is a write request.

What’s convenient about the body of isAuthorized is that you can run any Handler code you want. This means you can:

  • Access the filesystem (normal IO)

  • Lookup values in the database

  • Pull any session or request values you want

Using these techniques, you can develop as sophisticated an authorization system as you like, or even tie into existing systems used by your organization.

Conclusion

This chapter covered the basics of setting up user authentication, as well as how the built-in authorization functions provide a simple, declarative approach for users. While these are complicated concepts, with many approaches, Yesod should provide you with the building blocks you need to create your own customized auth solution.