Authentication and Authorization - Advanced - Developing Web Apps with Haskell and Yesod, Second Edition (2015)

Developing Web Apps with Haskell and Yesod, Second Edition (2015)

Part II. Advanced

Chapter 14. Authentication and Authorization

Authentication and authorization are conceptually related, but they are not one and the same. The former deals with identifying a user, whereas the latter determines what a user is allowed to do. Unfortunately, because both terms are frequently 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), and 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, because 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 plug-ins. 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 plug-in provides its own system for logging in, whether it be via passing tokens with an external site or a email/password form. After a successful login, the plug-in sets a value in the user’s session to indicate his 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 inId) give both the table ID and entity value.

All of the storage of AuthId is built on top of sessions, so the same 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, because 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.

NOTE

You can replace the default client-side sessions with server-side sessions to provide a forced logout capability, if this is desired.

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 or 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 adhoc 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:

{-# LANGUAGE MultiParamTypeClasses #-}

{-# LANGUAGE OverloadedStrings #-}

{-# LANGUAGE QuasiQuotes #-}

{-# LANGUAGE TemplateHaskell #-}

{-# LANGUAGE TypeFamilies #-}

import Data.Default (def)

import Data.Text (Text)

import Network.HTTP.Client.Conduit (Manager, newManager)

import Yesod

import Yesod.Auth

import Yesod.Auth.BrowserId

import Yesod.Auth.GoogleEmail

dataApp=App

{ httpManager ::Manager

}

mkYesod "App" [parseRoutes|

/ HomeRGET

/auth AuthRAuth getAuth

|]

instanceYesodAppwhere

-- Note: In order to log in with BrowserID, you must correctly

-- set your hostname here.

approot =ApprootStatic "http://localhost:3000"

instanceYesodAuthAppwhere

typeAuthIdApp=Text

getAuthId =return . Just . credsIdent

loginDest _=HomeR

logoutDest _=HomeR

authPlugins _=

[ authBrowserId def

, authGoogleEmail

]

authHttpManager =httpManager

-- The default maybeAuthId assumes a Persistent database. We're going for a

-- simpler AuthId, so we'll just do a direct lookup in the session.

maybeAuthId =lookupSession "_ID"

instanceRenderMessageAppFormMessagewhere

renderMessage __=defaultFormMessage

getHomeR ::HandlerHtml

getHomeR =do

maid <-maybeAuthId

defaultLayout

[whamlet|

<p>Your current auth ID: #{show maid}

$maybe _<-maid

<p>

<a href=@{AuthRLogoutR}>Logout

$nothing

<p>

<a href=@{AuthRLoginR}>Go to the login page

|]

main ::IO ()

main =do

man <-newManager

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:

/auth AuthR Auth getAuth

we need to have getAuth :: MyAuthSite -> Auth. Although 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.

NOTE

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, we want to have an AuthId associated type, so a typeclass is more natural. 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 six 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 the example, we’ll simply use Text to store the raw identifier (email address, in this case).

§ getAuthId gets the actual AuthId from the Creds (credentials) data type. This type has three pieces of information: the authentication backend used (BrowserID or Google Email, 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 BrowserID, which logs in via Mozilla’s BrowserID system, and Google Email, which authenticates a user’s email address using the user’s Google account. The nice thing about these two backends is:

§ They require no setup, as opposed to Facebook or OAuth, which require setting up credentials.

§ They use email addresses as identifiers, which people are comfortable with, as opposed to OpenID, which uses a URL.

§ authHttpManager gets an HTTP connection manager from the foundation type. This allow authentication backends that use HTTP connections (i.e., almost all third-party login systems) to share connections, avoiding the cost of restarting a TCP connection for each request.

In addition to these six 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, 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).

Figures 14-1 through 14-3 show what the login process looks like from a user’s perspective.

wahy 1401

Figure 14-1. Initial page load

wahy 1402

Figure 14-2. BrowserID login screen

wahy 1403

Figure 14-3. Homepage after logging in

Email

For many use cases, third-party authentication using email will be sufficient. Occasionally, however, 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.

§ Although 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.

NOTE

The following example 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 that provides a drop-in replacement for the sendmail code, which we’ll use. This is the approach I generally recommend, and it’s 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, because it needs to store passwords securely in the database and send a number of different emails to users (for account verification, password retrieval, etc.).

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

{-# LANGUAGE DeriveDataTypeable #-}

{-# LANGUAGE FlexibleContexts #-}

{-# LANGUAGE GADTs #-}

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

{-# LANGUAGE MultiParamTypeClasses #-}

{-# LANGUAGE OverloadedStrings #-}

{-# LANGUAGE QuasiQuotes #-}

{-# LANGUAGE TemplateHaskell #-}

{-# LANGUAGE TypeFamilies #-}

import Control.Monad (join)

import Control.Monad.Logger (runNoLoggingT)

import Data.Maybe (isJust)

import Data.Text (Text)

importqualifiedData.Text.Lazy.Encoding

import Data.Typeable (Typeable)

import Database.Persist.Sqlite

import Database.Persist.TH

import Network.Mail.Mime

import Text.Blaze.Html.Renderer.Utf8 (renderHtml)

import Text.Hamlet (shamlet)

import Text.Shakespeare.Text (stext)

import Yesod

import Yesod.Auth

import Yesod.Auth.Email

share [mkPersist sqlSettings { mpsGeneric =False }, mkMigrate "migrateAll"]

[persistLowerCase|

User

email Text

password TextMaybe -- Password may not be set yet

verkey TextMaybe -- Used for resetting passwords

verified Bool

UniqueUser email

derivingTypeable

|]

dataApp=AppSqlBackend

mkYesod "App" [parseRoutes|

/ HomeRGET

/auth AuthRAuth getAuth

|]

instanceYesodAppwhere

-- Emails will include links, so be sure to include an approot so that

-- the links are valid!

approot =ApprootStatic "http://localhost:3000"

instanceRenderMessageAppFormMessagewhere

renderMessage __=defaultFormMessage

-- Set up Persistent

instanceYesodPersistAppwhere

typeYesodPersistBackendApp=SqlBackend

runDB f =do

App conn <-getYesod

runSqlConn f conn

instanceYesodAuthAppwhere

typeAuthIdApp=UserId

loginDest _=HomeR

logoutDest _=HomeR

authPlugins _= [authEmail]

-- Need to find the UserId for the given email address.

getAuthId creds =runDB $ do

x <-insertBy $ User (credsIdent creds) NothingNothingFalse

return $ Just $

case x of

Left (Entity userid _) ->userid -- newly added user

Right userid ->userid -- existing user

authHttpManager =error "Email doesn't need an HTTP manager"

instanceYesodAuthPersistApp

-- Here's all of the email-specific code

instanceYesodAuthEmailAppwhere

typeAuthEmailIdApp=UserId

afterPasswordRoute _=HomeR

addUnverified email verkey =

runDB $ insert $ User email Nothing (Just verkey) False

sendVerifyEmail email _ verurl =

liftIO $ renderSendMail (emptyMail $ AddressNothing "noreply")

{ mailTo = [AddressNothing email]

, mailHeaders =

[ ("Subject", "Verify your email address")

]

, mailParts = [[textPart, htmlPart]]

}

where

textPart =Part

{ partType ="text/plain; charset=utf-8"

, partEncoding =None

, partFilename =Nothing

, partContent =Data.Text.Lazy.Encoding.encodeUtf8

[stext|

Please confirm your email address

by clicking on the link below.

#{verurl}

Thank you

|]

, partHeaders =[]

}

htmlPart =Part

{ partType ="text/html; charset=utf-8"

, partEncoding =None

, partFilename =Nothing

, partContent =renderHtml

[shamlet|

<p>Please confirm your email address

by clicking on the link below.

<p>

<a href=#{verurl}>#{verurl}

<p>Thank you

|]

, partHeaders =[]

}

getVerifyKey =runDB . fmap (join . fmap userVerkey) . get

setVerifyKey uid key =runDB $ update uid [UserVerkey =. Just key]

verifyAccount uid =runDB $ do

mu <-get uid

case mu of

Nothing->return Nothing

Just u ->do

update uid [UserVerified =. True]

return $ Just uid

getPassword =runDB . fmap (join . fmap userPassword) . get

setPassword uid pass =runDB $ update uid [UserPassword =. Just pass]

getEmailCreds email =runDB $ do

mu <-getBy $ UniqueUser email

case mu of

Nothing->return Nothing

Just (Entity uid u) ->return $ JustEmailCreds

{ emailCredsId =uid

, emailCredsAuthId =Just uid

, emailCredsStatus =isJust $ userPassword u

, emailCredsVerkey =userVerkey u

, emailCredsEmail =email

}

getEmail =runDB . fmap (fmap userEmail) . get

getHomeR ::HandlerHtml

getHomeR =do

maid <-maybeAuthId

defaultLayout

[whamlet|

<p>Your current auth ID: #{show maid}

$maybe _<-maid

<p>

<a href=@{AuthRLogoutR}>Logout

$nothing

<p>

<a href=@{AuthRLoginR}>Go to the login page

|]

main ::IO ()

main =runNoLoggingT $ withSqliteConn "email.db3" $ \conn ->liftIO $ do

runSqlConn (runMigration migrateAll) conn

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 look at an example:

{-# LANGUAGE MultiParamTypeClasses #-}

{-# LANGUAGE OverloadedStrings #-}

{-# LANGUAGE QuasiQuotes #-}

{-# LANGUAGE TemplateHaskell #-}

{-# LANGUAGE TypeFamilies #-}

import Data.Default (def)

import Data.Text (Text)

import Network.HTTP.Conduit (Manager, conduitManagerSettings,

newManager)

import Yesod

import Yesod.Auth

import Yesod.Auth.Dummy -- just for testing; don't use in real life!

dataApp=App

{ httpManager ::Manager

}

mkYesod "App" [parseRoutes|

/ HomeR GETPOST

/admin AdminRGET

/auth AuthR Auth getAuth

|]

instanceYesodAppwhere

authRoute _=Just $ AuthRLoginR

-- route name, then a Boolean indicating if it's a write request

isAuthorized HomeRTrue=isAdmin

isAuthorized AdminR_=isAdmin

-- anyone can access other pages

isAuthorized __=return Authorized

isAdmin =do

mu <-maybeAuthId

return $ case mu of

Nothing->AuthenticationRequired

Just "admin" ->Authorized

Just_->Unauthorized "You must be an admin"

instanceYesodAuthAppwhere

typeAuthIdApp=Text

getAuthId =return . Just . credsIdent

loginDest _=HomeR

logoutDest _=HomeR

authPlugins _= [authDummy]

authHttpManager =httpManager

maybeAuthId =lookupSession "_ID"

instanceRenderMessageAppFormMessagewhere

renderMessage __=defaultFormMessage

getHomeR ::HandlerHtml

getHomeR =do

maid <-maybeAuthId

defaultLayout

[whamlet|

<p>Note:Login as "admin" to be an administrator.

<p>Your current auth ID: #{show maid}

$maybe _<-maid

<p>

<a href=@{AuthRLogoutR}>Logout

<p>

<a href=@{AdminR}>Go to admin page

<form method=post>

Make a change (admins only)

\ #

<input type=submit>

|]

postHomeR ::Handler ()

postHomeR =do

setMessage "You made some change to the page"

redirect HomeR

getAdminR ::HandlerHtml

getAdminR =defaultLayout

[whamlet|

<p>I guess you're an admin!

<p>

<a href=@{HomeR}>Return to homepage

|]

main ::IO ()

main =do

manager <-newManager conduitManagerSettings

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 theisWriteRequest 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 in it. This means you can:

§ Access the filesystem (normal I/O).

§ Look up 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.

Summary

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. Although these are complicated concepts, with many approaches, Yesod should provide you with the building blocks you need to create your own customized auth solution.