{-# LANGUAGE AllowAmbiguousTypes #-} {-| Module: IHP.AuthSupport.Controller.Sessions Description: Provides action implementations for SessionControllers Copyright: (c) digitally induced GmbH, 2020 -} module IHP.AuthSupport.Controller.Sessions ( newSessionAction , createSessionAction , deleteSessionAction , SessionsControllerConfig (..) ) where import IHP.Prelude import IHP.ControllerPrelude hiding (Success, currentUserOrNothing) import IHP.AuthSupport.View.Sessions.New import IHP.ViewSupport (View) import Data.Data import qualified IHP.AuthSupport.Lockable as Lockable import System.IO.Unsafe (unsafePerformIO) -- | Displays the login form. -- -- In case the user is already logged in, redirects to the home page ('afterLoginRedirectPath'). newSessionAction :: forall record action. ( ?theAction :: action , ?context :: ControllerContext , HasNewSessionUrl record , ?modelContext :: ModelContext , Typeable record , View (NewView record) , Data action , Record record , HasPath action , SessionsControllerConfig record ) => IO () newSessionAction = do let alreadyLoggedIn = isJust (currentUserOrNothing @record) when alreadyLoggedIn (redirectToPath (afterLoginRedirectPath @record)) let user = newRecord @record render NewView { .. } {-# INLINE newSessionAction #-} -- | Logs in a user when a valid email and password is given -- -- After 10 failed attempts, the user is locked for an hours. See 'maxFailedLoginAttempts' to customize this. -- -- After a successful login, the user is redirect to 'afterLoginRedirectPath'. createSessionAction :: forall record action. (?theAction :: action , ?context :: ControllerContext , ?modelContext :: ModelContext , Data action , HasField "email" record Text , HasPath action , HasField "id" record (Id record) , HasField "passwordHash" record Text , SessionsControllerConfig record , UpdateField "lockedAt" record record (Maybe UTCTime) (Maybe UTCTime) , HasField "failedLoginAttempts" record Int , SetField "failedLoginAttempts" record Int , CanUpdate record , Show (PrimaryKey (GetTableName record)) , record ~ GetModelByTableName (GetTableName record) , Table record ) => IO () createSessionAction = do usersQueryBuilder |> filterWhereCaseInsensitive (#email, param "email") |> fetchOneOrNothing >>= \case Just (user :: record) -> do isLocked <- Lockable.isLocked user when isLocked do setErrorMessage "User is locked" redirectTo buildNewSessionAction if verifyPassword user (param @Text "password") then do beforeLogin user login user user <- user |> set #failedLoginAttempts 0 |> updateRecord redirectUrl <- getSessionAndClear "IHP.LoginSupport.redirectAfterLogin" redirectToPath (fromMaybe (afterLoginRedirectPath @record) redirectUrl) else do setErrorMessage "Invalid Credentials" user :: record <- user |> incrementField #failedLoginAttempts |> updateRecord when (user.failedLoginAttempts >= maxFailedLoginAttempts user) do Lockable.lock user pure () redirectTo buildNewSessionAction Nothing -> do setErrorMessage "Invalid Credentials" redirectTo buildNewSessionAction {-# INLINE createSessionAction #-} -- | Logs out the user and redirects to `afterLogoutRedirectPath` or login page by default deleteSessionAction :: forall record action id. ( ?theAction :: action , ?context :: ControllerContext , ?modelContext :: ModelContext , Data action , HasPath action , Show id , HasField "id" record id , SessionsControllerConfig record ) => IO () deleteSessionAction = do case currentUserOrNothing @record of Just user -> do beforeLogout user logout user Nothing -> pure () redirectToPath (afterLogoutRedirectPath @record) {-# INLINE deleteSessionAction #-} currentUserOrNothing :: forall user. (?context :: ControllerContext, HasNewSessionUrl user, Typeable user) => (Maybe user) currentUserOrNothing = case unsafePerformIO (maybeFromContext @(Maybe user)) of Just user -> user Nothing -> error "currentUserOrNothing: initAuthentication has not been called in initContext inside FrontController of this application" {-# INLINE currentUserOrNothing #-} -- | Returns the NewSessionAction action for the given SessionsController buildNewSessionAction :: forall controller. (?theAction :: controller, Data controller) => controller buildNewSessionAction = fromConstr createConstructor where createConstructor :: Constr (Just createConstructor) = find isNewSessionConstructor allConstructors allConstructors :: [Constr] allConstructors = dataTypeConstrs (dataTypeOf ?theAction) isNewSessionConstructor :: Constr -> Bool isNewSessionConstructor constructor = "NewSessionAction" == showConstr constructor {-# INLINE buildNewSessionAction #-} -- | Configuration for the session controller actions class ( Typeable record , Show record , KnownSymbol (GetModelName record) , HasNewSessionUrl record , KnownSymbol (GetTableName record) , FromRow record ) => SessionsControllerConfig record where -- | Your home page, where the user is redirect after login, by default it's @/@ afterLoginRedirectPath :: Text afterLoginRedirectPath = "/" -- | Where the user is redirected after logout, by default it's @/NewSession@ afterLogoutRedirectPath :: forall action. (?theAction :: action, Data action, HasPath action) => Text afterLogoutRedirectPath = pathTo buildNewSessionAction -- | After 10 failed login attempts the user will be locked for an hour maxFailedLoginAttempts :: record -> Int maxFailedLoginAttempts _ = 10 -- | Callback that is executed just before the user is logged in -- -- This is called only after checking that the password is correct. When a wrong password is given this callback is not executed. -- -- __Example: Disallow login until user is confirmed__ -- -- > beforeLogin user = do -- > unless (user.isConfirmed) do -- > setErrorMessage "Please click the confirmation link we sent to your email before you can use the App" -- > redirectTo NewSessionAction beforeLogin :: (?context :: ControllerContext, ?modelContext :: ModelContext) => record -> IO () beforeLogin _ = pure () -- | Callback that is executed just before the user is logged out -- -- This is called only if user session exists beforeLogout :: (?context :: ControllerContext, ?modelContext :: ModelContext) => record -> IO () beforeLogout _ = pure () -- | Return's the @query\ \@User@ used by the controller. Customize this to e.g. exclude guest users from logging in. -- -- __Example: Exclude guest users from login__ -- -- > usersQueryBuilder = query @User |> filterWhere (#isGuest, False) -- usersQueryBuilder :: (GetModelByTableName (GetTableName record) ~ record, Table record) => QueryBuilder (GetTableName record) usersQueryBuilder = query @record {-# INLINE usersQueryBuilder #-}