{-# LANGUAGE QuasiQuotes, TypeFamilies #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE FlexibleContexts #-} module Yesod.Auth.Email ( -- * Plugin authEmail , YesodAuthEmail (..) , EmailCreds (..) , saltPass -- * Routes , loginR , registerR , setpassR , isValidPass ) where import Network.Mail.Mime (randomString) import Yesod.Auth import System.Random import Text.Blaze.Html (toHtml) import Control.Monad (when) import Control.Applicative ((<$>), (<*>)) import Data.Digest.Pure.MD5 import qualified Data.Text.Lazy as T import qualified Data.Text as TS import Data.Text.Lazy.Encoding (encodeUtf8) import Data.Text (Text) import qualified Crypto.PasswordStore as PS import qualified Data.Text.Encoding as DTE import Yesod.Form import Yesod.Core import Control.Monad.IO.Class (liftIO) import qualified Yesod.Auth.Message as Msg loginR, registerR, setpassR :: AuthRoute loginR = PluginR "email" ["login"] registerR = PluginR "email" ["register"] setpassR = PluginR "email" ["set-password"] verify :: Text -> Text -> AuthRoute -- FIXME verify eid verkey = PluginR "email" ["verify", eid, verkey] type Email = Text type VerKey = Text type VerUrl = Text type SaltedPass = Text type VerStatus = Bool -- | Data stored in a database for each e-mail address. data EmailCreds m = EmailCreds { emailCredsId :: AuthEmailId m , emailCredsAuthId :: Maybe (AuthId m) , emailCredsStatus :: VerStatus , emailCredsVerkey :: Maybe VerKey } class (YesodAuth m, PathPiece (AuthEmailId m)) => YesodAuthEmail m where type AuthEmailId m addUnverified :: Email -> VerKey -> GHandler m (AuthEmailId m) sendVerifyEmail :: Email -> VerKey -> VerUrl -> GHandler m () getVerifyKey :: AuthEmailId m -> GHandler m (Maybe VerKey) setVerifyKey :: AuthEmailId m -> VerKey -> GHandler m () verifyAccount :: AuthEmailId m -> GHandler m (Maybe (AuthId m)) getPassword :: AuthId m -> GHandler m (Maybe SaltedPass) setPassword :: AuthId m -> SaltedPass -> GHandler m () getEmailCreds :: Email -> GHandler m (Maybe (EmailCreds m)) getEmail :: AuthEmailId m -> GHandler m (Maybe Email) -- | Generate a random alphanumeric string. randomKey :: m -> IO Text randomKey _ = do stdgen <- newStdGen return $ TS.pack $ fst $ randomString 10 stdgen authEmail :: YesodAuthEmail m => AuthPlugin m authEmail = AuthPlugin "email" dispatch $ \tm -> [whamlet| $newline never
_{Msg.Email}
_{Msg.Password}
I don't have an account |] where dispatch "GET" ["register"] = getRegisterR >>= sendResponse dispatch "POST" ["register"] = postRegisterR >>= sendResponse dispatch "GET" ["verify", eid, verkey] = case fromPathPiece eid of Nothing -> notFound Just eid' -> getVerifyR eid' verkey >>= sendResponse dispatch "POST" ["login"] = postLoginR >>= sendResponse dispatch "GET" ["set-password"] = getPasswordR >>= sendResponse dispatch "POST" ["set-password"] = postPasswordR >>= sendResponse dispatch _ _ = notFound getRegisterR :: YesodAuthEmail master => HandlerT Auth (GHandler master) RepHtml getRegisterR = do email <- newIdent mrender <- getMessageRender defaultLayoutT $ do setTitle $ toHtml $ mrender Msg.RegisterLong [whamlet|

#{mrender Msg.EnterEmail}

_{Msg.ConfirmationEmailSent email}|] getVerifyR :: YesodAuthEmail m => AuthEmailId m -> Text -> HandlerT Auth (GHandler m) RepHtml getVerifyR lid key = do realKey <- lift $ getVerifyKey lid memail <- lift $ getEmail lid case (realKey == Just key, memail) of (True, Just email) -> do muid <- lift $ verifyAccount lid case muid of Nothing -> return () Just _uid -> do setCreds False $ Creds "email" email [("verifiedEmail", email)] -- FIXME uid? mrender <- lift getMessageRender setMessage $ toHtml $ mrender Msg.AddressVerified redirect setpassR _ -> return () lift $ defaultLayout $ do setTitleI Msg.InvalidKey [whamlet|

_{Msg.InvalidKey}|] postLoginR :: YesodAuthEmail master => HandlerT Auth (GHandler master) () postLoginR = do (email, pass) <- lift $ runInputPost $ (,) <$> ireq emailField "email" <*> ireq textField "password" mecreds <- lift $ getEmailCreds email maid <- case (mecreds >>= emailCredsAuthId, fmap emailCredsStatus mecreds) of (Just aid, Just True) -> do mrealpass <- lift $ getPassword aid case mrealpass of Nothing -> return Nothing Just realpass -> return $ if isValidPass pass realpass then Just aid else Nothing _ -> return Nothing case maid of Just _aid -> setCreds True $ Creds "email" email [("verifiedEmail", email)] -- FIXME aid? Nothing -> do mrender <- lift getMessageRender setMessage $ toHtml $ mrender Msg.InvalidEmailPass redirect LoginR getPasswordR :: YesodAuthEmail master => HandlerT Auth (GHandler master) RepHtml getPasswordR = do maid <- lift maybeAuthId pass1 <- newIdent pass2 <- newIdent mrender <- lift getMessageRender case maid of Just _ -> return () Nothing -> do setMessage $ toHtml $ mrender Msg.BadSetPass redirect LoginR defaultLayoutT $ do setTitle $ toHtml $ mrender Msg.SetPassTitle -- FIXME make setTitleI more intelligent [whamlet| $newline never

#{mrender Msg.SetPass}
|] postPasswordR :: YesodAuthEmail master => HandlerT Auth (GHandler master) () postPasswordR = do (new, confirm) <- lift $ runInputPost $ (,) <$> ireq textField "new" <*> ireq textField "confirm" when (new /= confirm) $ do lift $ setMessageI Msg.PassMismatch redirect setpassR maid <- lift maybeAuthId aid <- case maid of Nothing -> do lift $ setMessageI Msg.BadSetPass redirect LoginR Just aid -> return aid salted <- liftIO $ saltPass new lift $ do y <- getYesod setPassword aid salted setMessageI Msg.PassUpdated redirect $ loginDest y saltLength :: Int saltLength = 5 -- | Salt a password with a randomly generated salt. saltPass :: Text -> IO Text saltPass = fmap DTE.decodeUtf8 . flip PS.makePassword 12 . DTE.encodeUtf8 saltPass' :: String -> String -> String saltPass' salt pass = salt ++ show (md5 $ fromString $ salt ++ pass) where fromString = encodeUtf8 . T.pack isValidPass :: Text -- ^ cleartext password -> SaltedPass -- ^ salted password -> Bool isValidPass ct salted = PS.verifyPassword (DTE.encodeUtf8 ct) (DTE.encodeUtf8 salted) || isValidPass' ct salted isValidPass' :: Text -- ^ cleartext password -> SaltedPass -- ^ salted password -> Bool isValidPass' clear' salted' = let salt = take saltLength salted in salted == saltPass' salt clear where clear = TS.unpack clear' salted = TS.unpack salted'