Authentication
- Quick-and-Dirty: HTTP Basic Auth
- Introduction - Real Authentication
- Setup
- Trying out the login
- Accessing the current user
- Performing actions on login
- Logout
- Creating a user
- Aside: Admin authentication
Quick-and-Dirty: HTTP Basic Auth
While IHP provides an authentication toolkit out of the box, it also provides a shortcut for cases where you just want the simplest possible way to enforce a hard-coded username/password before accessing your new web application. This shortcut leverages HTTP Basic Authentication built into all browsers:
instance Controller WidgetsController where
beforeAction = basicAuth "sanja" "hunter2" "myapp"
The parameters are: username, password, and authentication realm. The realm can be thought of as an area of validity for the credentials. It is common to put the project name, but it can also be blank (meaning the entire domain).
Introduction - Real Authentication
–
There’s an IHP Casts Episode on this part of the Documentation
–
The usual convention in IHP is to call your user record User
. When there is an admin user, we usually call the record Admin
. In general, the authentication can work with any kind of record. The only requirement is that it has an id field.
To use the authentication module, your users
table needs to have at least an id
, email
, password_hash
, locked_at
and failed_login_attempts
field. Add this to Schema.sql
:
CREATE TABLE users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
locked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
failed_login_attempts INT DEFAULT 0 NOT NULL
);
The password authentication saves the passwords as a salted hash using the pwstore-fast library. By default, a user will be locked for an hour after 10 failed login attempts.
Setup
Currently, the authentication toolkit has to be enabled manually. We plan to do this setup using a code generator in the future.
Adding a Session Controller
We need to add a new controller that will deal with the login and logout. We call this the SessionsController
.
First, we have to update Web/Types.hs
:
import IHP.LoginSupport.Types -- <---- ADD THIS IMPORT
-- ADD THIS TO THE END OF THE FILE
instance HasNewSessionUrl User where
newSessionUrl _ = "/NewSession"
type instance CurrentUserRecord = User
The instance HasNewSessionUrl User
tells the auth module where to redirect a user in case the user tries to access an action that requires login. The definition of CurrentUserRecord
tells the auth system to use our User
type within the login system.
We also need to add the type definitions for the SessionsController
:
data SessionsController
= NewSessionAction
| CreateSessionAction
| DeleteSessionAction
deriving (Eq, Show, Data)
After that we need to set up routing for our new controller in Web/Routes.hs
:
instance AutoRoute SessionsController
We also need to create a Web/Controller/Sessions.hs
calling the IHP authentication module:
module Web.Controller.Sessions where
import Web.Controller.Prelude
import Web.View.Sessions.New
import qualified IHP.AuthSupport.Controller.Sessions as Sessions
instance Controller SessionsController where
action NewSessionAction = Sessions.newSessionAction @User
action CreateSessionAction = Sessions.createSessionAction @User
action DeleteSessionAction = Sessions.deleteSessionAction @User
instance Sessions.SessionsControllerConfig User
Additionally, we need to implement a login view at Web/View/Sessions/New.hs
like this:
module Web.View.Sessions.New where
import Web.View.Prelude
import IHP.AuthSupport.View.Sessions.New
instance View (NewView User) where
html NewView { .. } = [hsx|
<div class="h-100" id="sessions-new">
<div class="d-flex align-items-center">
<div class="w-100">
<div style="max-width: 400px" class="mx-auto mb-5">
<h5>Please login</h5>
{renderForm user}
</div>
</div>
</div>
</div>
|]
renderForm :: User -> Html
renderForm user = [hsx|
<form method="POST" action={CreateSessionAction}>
<div class="form-group">
<input name="email" value={user.email} type="email" class="form-control" placeholder="E-Mail" required="required" autofocus="autofocus" />
</div>
<div class="form-group">
<input name="password" type="password" class="form-control" placeholder="Password"/>
</div>
<button type="submit" class="btn btn-primary btn-block">Login</button>
</form>
|]
Activating the Session
Open Web/FrontController.hs
. Add an import for IHP.LoginSupport.Middleware
and Web.Controller.Sessions
:
import IHP.LoginSupport.Middleware
import Web.Controller.Sessions
We then need to mount our session controller by adding parseRoute @SessionsController
:
instance FrontController WebApplication where
controllers =
[ startPage WelcomeAction
, parseRoute @SessionsController -- <--------------- add this
-- Generator Marker
]
At the end of the file, there is a line like:
instance InitControllerContext WebApplication where
initContext = do
setLayout defaultLayout
initAutoRefresh
We need to extend this function with a initAuthentication @User
like this:
instance InitControllerContext WebApplication where
initContext = do
setLayout defaultLayout
initAutoRefresh
initAuthentication @User
This will fetch the user from the database when a userId
is given in the session. The fetched user record is saved to the special ?context
variable and is used by all the helper functions like currentUser
.
Trying out the login
After you have completed the above steps, you can open the login at /NewSession
. You can generate a link to your login page like this:
<a href={NewSessionAction}>Login</a>
Accessing the current user
Inside your actions you can then use currentUser
to get access to the current logged in user:
action MyAction = do
let text = "Hello " <> currentUser.email
renderPlain text
In case the user is logged out, an exception will be thrown when accessing currentUser
and the browser will automatically be redirected to the NewSessionAction
.
You can use currentUserOrNothing
to manually deal with the not-logged-in case:
action MyAction = do
case currentUserOrNothing of
Just currentUser -> do
let text = "Hello " <> currentUser.email
renderPlain text
Nothing -> renderPlain "Please login first"
Additionally you can use currentUserId
as a shortcut for currentUser.id
.
You can also access the user using currentUser
inside your views:
[hsx|
<h1>Hello {currentUser.email}</h1>
|]
Performing actions on login
The SessionsController
has a convenient beforeLogin
which is run on login after the user is authenticated, but before the target page is rendered. This can be useful for updating last login time, number of logins or aborting the login when the user is blocked. Add code for it in your Web/Controller/Sessions.hs
. To update number of logins (requires logins
integer field in Users
table):
instance Sessions.SessionsControllerConfig User where
beforeLogin = updateLoginHistory
updateLoginHistory user = do
user
|> modify #logins (\count -> count + 1)
|> updateRecord
pure ()
To block login (requires isConfirmed
boolean field in Users
table):
instance Sessions.SessionsControllerConfig User where
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
Logout
You can simply render a link inside your layout or view to send the user to the logout:
<a class="js-delete js-delete-no-confirm" href={DeleteSessionAction}>Logout</a>
Creating a user
We have the login now, but we still need be able to register a user. On the IDE, right click over the users
table and select “Generate Controller”.
Creating a user is similar to creating any other record. However, one notable difference is that we need to hash the password. We can do that by calling the hashing function before saving it into the database:
-- Web/Controller/Users.hs
action CreateUserAction = do
let user = newRecord @User
-- The value from the password confirmation input field.
let passwordConfirmation = param @Text "passwordConfirmation"
user
|> fill @["email", "passwordHash"]
-- We ensure that the error message doesn't include
-- the entered password.
|> validateField #passwordHash (isEqual passwordConfirmation |> withCustomErrorMessage "Passwords don't match")
|> validateField #passwordHash nonEmpty
|> validateField #email isEmail
-- After this validation, since it's operation on the IO, we'll need to use >>=.
|> validateIsUnique #email
>>= ifValid \case
Left user -> render NewView { .. }
Right user -> do
hashed <- hashPassword user.passwordHash
user <- user
|> set #passwordHash hashed
|> createRecord
setSuccessMessage "You have registered successfully"
redirectToPath "/"
The view would look like this, removing the failedLoginAttempts
that was generated by the IDE, and manually add a password confirmation input field.
-- Web/View/Users/New.hs
renderForm :: User -> Html
renderForm user = formFor user [hsx|
{(textField #email)}
{(passwordField #passwordHash) {fieldLabel = "Password", required = True}}
{(passwordField #passwordHash) { required = True, fieldLabel = "Password confirmation", fieldName = "passwordConfirmation", validatorResult = Nothing }}
{submitButton}
|]
You’ll notice we have two passwordFields. The first one is the password field, and the second one is the password confirmation field. We need to add a fieldName
to the second one, so that it will be submitted as passwordConfirmation
instead of passwordHash
. We also need to set the validatorResult
to Nothing
, so that the validation error message doesn’t show up twice.
We could have hand written <input type="password" name="passwordConfirmation" required/>
, however we’d like the theming of the fields to be consistent.
Editing a User
When editing an existing user we need to special case the password handling. A user may edit their info, but without changing their password. In this case we don’t want to re-hash the empty input. Furthermore, we want to make sure that when we present the form, we don’t populate the password field with the hashed password!
-- Web/Controller/Users.hs
action UpdateUserAction { userId } = do
user <- fetch userId
let originalPasswordHash = user.passwordHash
-- The value from the password confirmation input field.
let passwordConfirmation = param @Text "passwordConfirmation"
user
|> fill @["email", "passwordHash"]
-- We only validate the email field isn't empty, as the password
-- can remain empty. We ensure that the error message doesn't include
-- the entered password.
|> validateField #passwordHash (isEqual passwordConfirmation |> withCustomErrorMessage "Passwords don't match")
|> validateField #passwordHash nonEmpty
|> validateField #email isEmail
-- After this validation, since it's operation on the IO, we'll need to use >>=.
|> validateIsUnique #email
>>= ifValid \case
Left user -> render EditView { .. }
Right user -> do
-- If the password hash is empty, then the user did not
-- change the password. So, we set the password hash to
-- the original password hash.
hashed <-
if user.passwordHash == ""
then pure originalPasswordHash
else hashPassword user.passwordHash
user <- user
|> set #passwordHash hashed
|> updateRecord
setSuccessMessage "User updated"
redirectTo EditUserAction { .. }
In the case of Editing, the password and password confirmation are optional. If however, the user has changed the password, they will also need to confirm it.
-- Web/View/Users/Edit.hs
renderForm :: User -> Html
renderForm user = formFor user [hsx|
{(textField #email)}
{(passwordField #passwordHash) {fieldLabel = "Password"}}
{(passwordField #passwordHash) { fieldLabel = "Password confirmation", fieldName = "passwordConfirmation", validatorResult = Nothing }}
{submitButton}
|]
Hashing a Password via CLI
To manually insert a user into your database you need to hash the password first. You can do this by calling the hash-password
tool from your command line:
$ hash-password
Enter your password and press enter:
hunter2
sha256|17|Y32Ga1uke5CisJvVp6p2sg==|TSDuEs1+Xdaels6TYCkyCgIBHxWA/US7bvBlK0vHzvc=
Use hashPassword
to hash a password from inside your application.
Email Confirmation
Requires IHP Pro
In production apps you typically want to send a confirmation email to the user before the user can log in.
To enable email confirmation add a confirmation_token
and is_confirmed
column to your users
table:
CREATE TABLE users (
/* ... */
confirmation_token TEXT DEFAULT NULL,
is_confirmed BOOLEAN DEFAULT false NOT NULL
);
Confirmation Action
First we need to create a new confirmation action to our UsersController
.
Open Web/Types.hs
and add a | ConfirmUserAction { userId :: !(Id User), confirmationToken :: !Text }
to the data UsersController
:
data UsersController
= NewUserAction
-- ...
| ConfirmUserAction { userId :: !(Id User), confirmationToken :: !Text } -- <--- ADD THIS ACTION
deriving (Eq, Show, Data)
Next open Web/Controller/Users.hs
and:
-
Add these imports to the top of the file:
import qualified IHP.AuthSupport.Controller.Confirmations as Confirmations import qualified Web.Controller.Sessions ()
-
Add this action implementation:
action ConfirmUserAction { userId, confirmationToken } = Confirmations.confirmAction userId confirmationToken
This will delegate all calls of the confirm action to the IHP confirmation action.
-
Add an instance of
Confirmations.ConfirmationsControllerConfig User
:instance Confirmations.ConfirmationsControllerConfig User where
This instance can be used to customize the confirmation process. E.g. to send an welcome email after confirmation. For now we don’t customize anything here yet, therefore we leave the instance empty.
Confirmation Mail
Next we need to send out the confirmation mail.
Create a new file at Web/Mail/Users/ConfirmationMail.hs
and copy paste the following template in there:
module Web.Mail.Users.ConfirmationMail where
import Web.View.Prelude
import IHP.MailPrelude
import IHP.AuthSupport.Confirm
instance BuildMail (ConfirmationMail User) where
subject = "Confirm your Account"
to ConfirmationMail { .. } = Address { addressName = Nothing, addressEmail = user.email }
from = "someone@example.com"
html ConfirmationMail { .. } = [hsx|
Hey,
just checking it's you.
<a href={urlTo (ConfirmUserAction user.id confirmationToken)} target="_blank">
Activate your Account
</a>
|]
You can change this email to your liking.
Sending the Confirmation Mail
To send out the confirmation mail, open your registration action. Typically this is the CreateUserAction
in Web/Controller/Users.hs
.
-
Add imports
import IHP.AuthSupport.Confirm import Web.Mail.Users.ConfirmationMail
-
Call
sendConfirmationMail user
after the user has been created inaction CreateUserAction
:action CreateUserAction = do let user = newRecord @User user |> fill @["email", "passwordHash"] |> validateField #email isEmail |> validateField #passwordHash nonEmpty |> ifValid \case Left user -> render NewView { .. } Right user -> do hashed <- hashPassword user.passwordHash user <- user |> set #passwordHash hashed |> createRecord sendConfirmationMail user -- <------ ADD THIS FUNCTION CALL TO YOUR ACTION -- We can also customize the flash message text to let the user know that we have sent him an email setSuccessMessage $ "Welcome onboard! Before you can start, please quickly confirm your email address by clicking the link we've sent to " <> user.email redirectTo NewSessionAction
Now whenever a user registers, he will receive our confirmation mail.
Disallowing Login for unconfirmed Users
We still need to ensure that a user cannot log in before the email is confirmed.
Open Web/Controller/Sessions.hs
and:
-
Add an import to
IHP.AuthSupport.Confirm
at the top of the file:import qualified IHP.AuthSupport.Confirm as Confirm
-
Append a call to
Confirm.ensureIsConfirmed user
to thebeforeLogin
function ofSessionsControllerConfig
:instance Sessions.SessionsControllerConfig User where beforeLogin user = do Confirm.ensureIsConfirmed user
Now all logins by users that are not confirmed are blocked.
Optional: Send a Welcome Email after Confirmation (After-Confirmation Hook)
You can use the ConfirmationsControllerConfig
instance defined in Web/Controller/Users.hs
to run any code after the user is confirmed:
-- Web/Controller/Users.hs
instance Confirmations.ConfirmationsControllerConfig User where
afterConfirmation user = do
-- This code here is called whenever a user was confirmed
A common scenario is to send a welcome email after the user is confirmed. Let’s asume you’ve already created a new WelcomeEmail
using the Email Code Generator. You can then use this to send the welcome email after confirmation:
instance Confirmations.ConfirmationsControllerConfig User where
afterConfirmation user = do
sendMail WelcomeMail { user }
Aside: Admin authentication
If you are creating an admin sub-application, first use the code generator to create an application called Admin
, then follow this guide replacing Web
with Admin
and User
with Admin
everywhere (except for the lower-case user
in the file Admin/View/Sessions/New.hs
, which comes from an imported module).