{-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} -- | -- Module : Snap.Snaplet.ReCaptcha -- Copyright : (c) Mike Ledger 2014 -- (c) Lars Petersen 2012 -- -- License : BSD-style -- -- Maintainer : mike@quasimal.com, info@lars-petersen.net -- Stability : experimental -- Portability : portable -- -- This is a snaplet for google's ReCaptcha verification api. This library uses -- `http-conduit` and keeps connections alive (a maximum of 10). This is an -- important point in order to avoid denial of service attacks. -- -- See "Snap.Snaplet.ReCaptcha.Example" and the docs provided here for example -- usage. -- module Snap.Snaplet.ReCaptcha ( -- * Snaplet and Initialization ReCaptcha () , HasReCaptcha (..) , initReCaptcha , initReCaptcha' -- * Handlers , checkCaptcha , withCaptcha , getCaptcha -- * Types , Captcha(..) , PrivateKey , SiteKey -- * Extra , cstate , recaptchaScript, recaptchaDiv ) where import qualified Blaze.ByteString.Builder as Blaze import Control.Applicative import Control.Lens import Control.Monad.Reader (runReaderT) import qualified Data.Aeson as JSON import qualified Data.Aeson.TH as JSON import qualified Data.ByteString.Char8 as BS import qualified Data.Configurator as Conf import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Data.Foldable (fold, for_, toList) import Data.Monoid import Data.Typeable import Heist import Heist.Compiled import qualified Network.HTTP.Client.Conduit as HTTP import Snap import Snap.Snaplet.Heist.Compiled type PrivateKey = BS.ByteString type SiteKey = BS.ByteString type UserIP = BS.ByteString type UserAnswer = BS.ByteString data Captcha = Success | Failure -- | Errors returned by the Captcha. See -- for possible error codes. Note that 'Failure' is used for the case that the -- only error code returned is "invalid-input-response". | Errors [Text] -- | The server didn't respond with the JSON object required as per -- | InvalidServerResponse -- | There was no "recaptcha_response_field" parameter set in the user request. | MissingResponseParam deriving (Show, Typeable) data ReCaptcha = ReCaptcha { connectionManager :: !HTTP.Manager , recaptchaQuery :: !(UserIP -> UserAnswer -> HTTP.Request) , _cstate :: !Captcha } deriving (Typeable) makeLenses ''ReCaptcha class HasReCaptcha b where captchaLens :: SnapletLens (Snaplet b) ReCaptcha instance HasReCaptcha ReCaptcha where captchaLens = id -- This is kinda lame - should we just parse it manually? data ReCaptchaResponse = ReCaptchaResponse { success :: !Bool , error_codes :: !(Maybe [Text]) } JSON.deriveJSON JSON.defaultOptions ''Captcha JSON.deriveFromJSON JSON.defaultOptions { JSON.fieldLabelModifier = map $ \c -> case c of '_' -> '-' _ -> c } ''ReCaptchaResponse initialiser :: Maybe (Snaplet (Heist b)) -> (SiteKey, PrivateKey) -> Initializer b v ReCaptcha initialiser mheist (site,key) = do -- this has to parse for the snaplet to work at all req <- liftIO (HTTP.parseUrl "https://www.google.com/recaptcha/api/siteverify") man <- liftIO HTTP.newManager for_ mheist (\heist -> addReCaptchaHeist heist site) return ReCaptcha { connectionManager = man , recaptchaQuery = \ip answer -> HTTP.urlEncodedBody [ ("secret" , key) , ("response" , answer) , ("remoteip" , ip) ] req , _cstate = Failure } -- | Initialise the 'ReCaptcha' snaplet. You are required to have "site_key" and -- "secret_key" set in the snaplet's configuration file. See 'initReCaptcha\'' -- if you don't want to use Snap's snaplet configuration mechanism. -- -- This provides optional Heist support, which is implemented using -- 'recaptchaScript' and 'recaptchaDiv'. initReCaptcha :: Maybe (Snaplet (Heist b)) -> SnapletInit b ReCaptcha initReCaptcha heist = makeSnaplet "recaptcha" "ReCaptcha integration" Nothing $ initialiser heist =<< do conf <- getSnapletUserConfig (,) <$> require conf "site_key" <*> require conf "secret_key" where require conf field = do v <- liftIO (Conf.lookup conf field) case v of Just v' -> return v' Nothing -> do spath <- BS.pack `fmap` getSnapletFilePath err <- errorMsg (encodeUtf8 field <> " not in the config " <> spath <> "/devel.cfg") fail (BS.unpack err) -- | Same as 'initReCaptcha', but passing the site key and private key -- explicitly - no configuration on the filesystem is required. initReCaptcha' :: Maybe (Snaplet (Heist b)) -> (SiteKey, PrivateKey) -> SnapletInit b ReCaptcha initReCaptcha' heist keys = makeSnaplet "recaptcha" "ReCaptcha integration" Nothing (initialiser heist keys) addReCaptchaHeist :: Snaplet (Heist b) -> BS.ByteString -> Initializer b v () addReCaptchaHeist heist site = addConfig heist $ mempty &~ do scCompiledSplices .= do "recaptcha-div" ## pureSplice id (return (recaptchaDiv site)) "recaptcha-script" ## pureSplice id (return recaptchaScript) -- | @ @ recaptchaScript :: Blaze.Builder recaptchaScript = Blaze.fromByteString "" -- | For use in a HTML form. recaptchaDiv :: BS.ByteString -> Blaze.Builder recaptchaDiv site = Blaze.fromByteString $! "
" -- | Get the ReCaptcha result by querying Google's API. -- -- This requires a "g-recaptcha-response" (POST) parameter to be set in the -- current request. -- -- See 'ReCaptchaResult' for possible failure types. -- -- @ -- cstate <- getCaptcha -- case cstate of -- Success -> writeText "Congratulations! You won." -- Failure -> writeText "Incorrect cstate answer." -- MissingResponseParam -> writeText "No g-recaptcha-response in POST" -- InvalidServerResponse -> writeText "Did Google change their API?" -- Errors errs -> writeText ("Errors: " <> 'T.pack' ('show' errs)) -- @ -- -- This may throw a 'HTTP.HttpException' if there is a connection-related error. getCaptcha :: HasReCaptcha b => Handler b c Captcha getCaptcha = do mresponse <- getPostParam "g-recaptcha-response" case mresponse of Just answer -> withTop' captchaLens $ do manager <- gets connectionManager getQuery <- gets recaptchaQuery remoteip <- getsRequest rqRemoteAddr response <- runReaderT (HTTP.httpLbs (getQuery remoteip answer)) manager -- The reply is a JSON object looking like -- { -- "success": true|false, -- "error-codes": [...] // optional -- } -- see -- we just use aeson and the derived FromJSON instance here return $! case JSON.decode (HTTP.responseBody response) of Just obj | success obj -> Success | invalidInput obj -> Failure | otherwise -> Errors (fold (error_codes obj)) Nothing -> InvalidServerResponse Nothing -> return MissingResponseParam where invalidInput obj = error_codes obj == Just ["invalid-input-response"] -- | Run one of two handlers on either failing or succeeding a captcha. -- -- @ -- 'withCaptcha' banForever $ do -- postId <- 'getParam' "id" -- thing <- 'getPostParam' thing -- addCommentToDB postId thing -- @ -- -- See 'getCaptcha' withCaptcha :: HasReCaptcha b => Handler b c () -- ^ Ran on failure -> Handler b c () -- ^ Ran on success -> Handler b c () withCaptcha onFail onSuccess = do s <- getCaptcha withTop' captchaLens (cstate .= s) case s of Success -> onSuccess _ -> onFail -- | 'pass' if the cstate failed. Logs errors (not incorrect captchas) with -- 'logError'. -- -- @ 'checkCaptcha' '<|>' 'writeText' "Captcha failed!" @ -- -- See 'getCaptcha' checkCaptcha :: HasReCaptcha b => Handler b c () checkCaptcha = do s <- getCaptcha withTop' captchaLens (cstate .= s) case s of Success -> return () Failure -> pass someError -> do logError =<< errorMsg (BS.pack (show someError)) pass errorMsg :: (MonadSnaplet m, Monad (m b v)) => BS.ByteString -> m b v BS.ByteString errorMsg err = do ancestry <- getSnapletAncestry name <- getSnapletName return $! showTextList (ancestry++toList name) <> " (ReCaptcha) : " <> err where showTextList :: [Text] -> BS.ByteString showTextList = BS.intercalate "/" . map encodeUtf8