Skip to content

UserRecipes

rivo edited this page Nov 26, 2017 · 3 revisions

While this package provides basic session handling (see session recipes), its main strength is in providing authentication functions for the users of your system. The following code snippets illustrate how you may use the package for this purpose.

Note that there is now an entire package github.com/rivo/users that implements the functions illustrated below in a much more comprehensive fashion. View these examples as starting points for your own system (or, better, use github.com/rivo/users).

An Example User Type

The User type in this package is an interface. You may use any type for your users, as long as you implement the interface's methods. There is only one requirement:

Each user must have a unique user ID. This user ID can be of any comparable type, e.g. an int, a string, or a struct composed of comparable types.

For our examples, we define a very simple user type with a string ID generated by the package's CUID() function which works well for user IDs. The ID is only used to identify the user internally. It is never exposed.

The user identifies themselves with their email address. If you decide to use a different method to identify the user, some of the examples given here may change slightly.

const (
	StateCreated = iota
	StateVerified
	StateExpired
)

type MyUser struct {
	ID           string
	Email        string
	PasswordHash []byte
	State        int
	TemporaryID  string
	IDDate       time.Time
}

func NewUser(email string, passwordHash []byte) *MyUser {
	temporaryID, err := sessions.RandomID(22)
	if err != nil {
		panic(err)
	}
	return &MyUser{
		ID:           sessions.CUID(),
		Email:        email,
		PasswordHash: passwordHash,
		State:        StateCreated,
		TemporaryID:  temporaryID,
		IDDate:       time.Now(),
	}
}

func (u *MyUser) GetID() interface{} {
	return u.ID
}

func (u *MyUser) GetRoles() []string {
	return nil
}

We'll ignore roles for now. But note that every user has a "state" and a "temporary ID" along with a date when that ID was created. We will use this in the examples below.

Creating a User

When someone signs up for your service, a new user needs to be created and saved in the database. This is a two-step process:

  1. First, the user is created by entering an email address and a password. This puts them into the StateCreated state. In this state, the user cannot use the system yet.
  2. To start using the system, the user needs to verify the account by clicking on the link sent to them after the account was created.

This procedure has a number of advantages which we will get into below. But the main advantage is that we're making sure that the user can receive emails at the provided address. (Of course, the disadvantage is that it's a more complicated process and the chances of the user abandoning the signup process are increased.)

func createUser(response http.ResponseWriter, request *http.Request) {
	email := strings.ToLower(request.FormValue("email"))
	password := request.FormValue("password")

	// Check password integrity.
	if result := sessions.ReasonablePassword(password,nil); result != sessions.PasswordOK {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Invalid password")
		return
	}

	// Generate password hash.
	hash, err := bcrypt.GenerateFromPassword([]byte(password), 0)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	// Create a new user and save them.
	user := NewUser(email, hash)
	existed, err := saveUserAtomic(user)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}

	// Send an email for verification.
	var subject, text string
	if existed {
		// We already had this user in our database.
		subject = "Message from example.com"
		text = `This email is to inform you that someone has attempted to create
			an account on example.com using your email address. If you did this
			yourself, please note that you already had an account with us. Please use
			example.com/passwordreset to recover your password. If you did not sign up
			for example.com yourself, please get in touch with customer support at
			support@example.com.

			Date of account creation: $date$
			IP address: $ip$
			User agent: $agent$
			Sent to: $email$`
	} else {
		// This user was successfully created.
		subject = "Please verify your example.com account"
		text = `To complete the registration of your user account at example.com,
			please click the following link:

			https://example.com/verify?id=$verification$

			This link is valid until $validity$.

			If you have not created an account on example.com yourself, someone may
			have been using your email address or mistyped their own email address.
			Please get in touch with support at support@example.com.

			Date of account creation: $date$
			IP address: $ip$
			User agent: $agent$
			Sent to: $email$`
	}
	replacements := map[string]string{
		"email":        email,
		"date":         time.Now().Format("Mon, Jan 2, 2006, 15:04:05"),
		"ip":           request.RemoteAddr,
		"agent":        request.UserAgent(),
		"verification": user.TemporaryID,
		"validity":     user.IDDate.Add(24 * time.Hour).Format("Mon, Jan 2, 2006, 15:04:05"),
	}
	for field, value := range replacements {
		text = strings.Replace("$"+text+"$", field, value, -1)
	}
	if err := sendEmail(email, subject, text); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}

	fmt.Fprint(response, `A verification email has been sent. If you do not
		receive the verification email, please contact support@example.com.`)
}

Creating a user requires an email and a password. The password is first checked using the ReasonablePassword() convenience function (see its documentation for more information) and then hashed using "bcrypt" (via golang.org/x/crypto/bcrypt).

We then create a new user and save them in the database. We won't get into the details of what saveUserAtomic() does because it will highly depend on the database you use. But it should perform basic checks, for example if the email address is valid and if it already exists (in which case the existed return value should be true). Make sure to avoid race conditions (which is why we use the term "atomic"). That is, it must be impossible to create the same user multiple times by issuing many similar requests at the same time. Unique key constraints are a good database tool to prevent such situations.

Depending on whether an entry for this email address already existed, we send one of two emails to that email address. If the email address already existed, we notify that user of that fact, along with instructions on how to deal with it. If the email had not yet existed and the user was newly created, we send a verification email. We also include the date, IP address, and user agent for further information in case it is needed for further research.

In any case, the immediate confirmation message is always the same. This way, the registration process cannot be used to find out whether someone is an existing user of the system.

Verifying the User

To verify the user, we compare the verification ID and check if it's valid. (This requires a lookup of the user by their TemporaryID field.) In case the verification ID has expired, we send the verification email again. For brevity, the verification email code is not repeated here.

Once the user verified, we update their status and redirect them to the login page.

func verifyUser(response http.ResponseWriter, request *http.Request) {
	// Find the user for this verification ID.
	verificationID := request.FormValue("id")
	user, err := loadUserViaTempID(verificationID)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	if user == nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "No user found for this verification ID.")
		return
	}

	// Is the verification ID still valid?
	if user.IDDate.Add(24 * time.Hour).Before(time.Now()) {
		// ...At this point, send another verification email. See createUser() for details...
		fmt.Fprint(response, "This verification ID has expired. We have sent a new one.")
		return
	}

	// User has been verified. Update status.
	user.State = StateVerified
	user.TemporaryID = ""
	user.Save()

	// Redirect the user to the login page.
	http.Redirect(response, request, "/login", 302)
}

Logging In

The following code shows how an existing user can be logged in:

func logIn(response http.ResponseWriter, request *http.Request) {
	email := strings.ToLower(request.FormValue("email"))
	password := request.FormValue("password")

	// Wait a second.
	time.Sleep(time.Second)

	// Load user.
	user, err := loadUserViaEmail(email)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	if user == nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Wrong email and/or password")
		return
	}

	// Check password.
	if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Wrong email and/or password")
		return
	}

	// Is the user in the correct state?
	if user.State != StateVerified {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "This account has not yet been verified.")
		// We may send another verification email at this point.
		return
	}

	// Log them in.
	session, err := sessions.Start(response, request, true)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}
	if err := session.Login(user, false, response); err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	fmt.Fprint(response, "You are logged in")
}

First, we wait one second to reduce an attacker's chances of brute-forcing their way into our system. We then load the user from the database given their email address. This gives us their password hash, which we then compare to the provided password. Note that the error messages from loadUserViaEmail() and the password comparison are the same. If they were different, someone would be able to check if a specific user is contained in our database.

We then retrieve the current session (or create a new one if it doesn't exist) and attach a user to it. In this example, the second argument to the Login() function is false. This allows a user to be logged in from multiple browsers at the same time, e.g. a desktop browser and a mobile browser. If you change this to true, logging in will cause all other sessions of that user to be destroyed first. They can then only be logged in on one browser at a time.

Accessing the Current User

On most webpages within our application, we will want to check if a user is logged in or not. The following code shows how that may be done:

func somePage(response http.ResponseWriter, request *http.Request) {
	// Never cache this page.
	response.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
	response.Header().Set("Pragma", "no-cache")
	response.Header().Set("Expires", "0")

	// Get the session.
	session, err := sessions.Start(response, request, false)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	// If the user is not logged in, redirect them to the login page.
	if session == nil || session.User() == nil {
		http.Redirect(response, request, "/login", 302)
		return
	}

	// We need the user to be verified.
	user := session.User().(*MyUser)
	if user.State != StateVerified {
		// We could be more specific here if we checked each available state.
		fmt.Fprint(response, "You are not authorized to access this page")
		return
	}

	fmt.Fprint(response, "You are logged in")
}

Note that we do not provide a return URL when redirecting a user to the login page. You may do that out of convenience to the user. However, always be sure to validate the return URL as failing to do so presents an attack vector for phishing (and other) attacks. It's best to filter it against a whitelist of acceptable URLs within your application.

Logging Out

The following code logs a user out of the application:

func logOut(response http.ResponseWriter, request *http.Request) {
	// Get the session.
	session, err := sessions.Start(response, request, false)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	// If there is no session or if no user is attached to it,
	// the user is already logged out.
	if session == nil || session.User() == nil {
		fmt.Fprint(response, "You are already logged out")
		return
	}

	// Log the user out.
	session.Logout()

	// Destroying the session is optional.
	if err := session.Destroy(response, request); err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	fmt.Fprint(response, "You have been logged out")
}

The last step of destroying the session is optional. It will cause the session data to be deleted from the internal cache, the session database, as well as the session cookie to be deleted from the user's browser.

Invalidating All Sessions

Sometimes, for security reasons, you may want to log a user out of all sessions. If this is supposed to occur during a regular log-in, simply provide true to the "exclusive" argument of the Login() function.

You may also call sessions.Logout(userID) directly, for example in response to a button labeled "Log me out of all devices". The user will then be detached from all sessions. Note that this requires a working implementation of the Persistence.UserSessions() function.

Password Reset

It is often necessary to provide a way for a user to regain access to the system when they have forgotten their password. At the same time, we want to minimize the potential for attackers to use the password reset routine to hijack an account.

In our example, there are three steps to recover a lost password:

  1. Enter the email address for which the password needs to be reset.
  2. Receive an email with a password reset link.
  3. Reset password.

This procedure assumes that the user is the only one who has access to their email address. If there is a chance that that's not the case, you may want to consider a different procedure.

The following code shows how the reset link is generated and sent.

func resetPassword(response http.ResponseWriter, request *http.Request) {
	// Check if we know this user.
	email := strings.ToLower(request.FormValue("email"))
	user, err := loadUserViaEmail(email)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}

	// Send an email.
	var subject, text string
	replacements := map[string]string{
		"email": email,
		"date":  time.Now().Format("Mon, Jan 2, 2006, 15:04:05"),
		"ip":    request.RemoteAddr,
		"agent": request.UserAgent(),
	}
	if user != nil && user.State == StateVerified {
		// User exists. Make a token.
		token, err := sessions.RandomID(22)
		if err != nil {
			response.WriteHeader(http.StatusBadRequest)
			fmt.Fprint(response, err)
			return
		}
		user.TemporaryID = token
		user.IDDate = time.Now()
		user.Save()

		// Send a password reset email.
		replacements["verification"] = user.TemporaryID
		replacements["validity"] = user.IDDate.Add(30 * time.Minute).Format("Mon, Jan 2, 2006, 15:04:05")
		subject = "Your link to reset your example.com password"
		text = `You have initiated a password reset procedure for your account at
			example.com. To change your password, please use the following link:

			https://example.com/resetpassword?id=$token$

			This link is valid until $validity$.

			If you have not requested to reset your password on example.com yourself,
			someone may have been using your email address or mistyped their own email
			address. Please get in touch with support at support@example.com.

			Date of account creation: $date$
			IP address: $ip$
			User agent: $agent$
			Sent to: $email$`
	} else {
		// User does not exist. Send some information about what happened.
		subject = "A password reset procedure was initiated on example.com"
		text = `This is a notification from example.com. You (or someone else)
			entered this email address when trying to change the password of an
			example.com account. However, this email address is not on our database
			of registered and verified users and therefore the attempted password
			change has failed.

			If you are an example.com user and were expecting this email, please try
			again using the email you gave when opening your account.

			If you are not an example.com user, please ignore this email.

			You may also get in touch with support at support@example.com.

			Date of account creation: $date$
			IP address: $ip$
			User agent: $agent$
			Sent to: $email$`
	}
	for field, value := range replacements {
		text = strings.Replace("$"+text+"$", field, value, -1)
	}
	if err := sendEmail(email, subject, text); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}

	fmt.Fprint(response, "Password reset email was sent")
}

Note that an email is sent regardless of whether an account with that email address exists or not, and the confirmation message is the same in both cases. This is to avoid giving users a way to find out if an account for a specific email address exists.

We also provide extensive information about the password request, especially for cases where a password reset email was received unexpectedly.

The code to generate the password reset page looks something like this:

func resetPage(response http.ResponseWriter, request *http.Request) {
	// Find the user for this password reset token.
	token := request.FormValue("token")
	user, err := loadUserViaTempID(token)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	if user == nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "No user found for this token.")
		return
	}

	// Is the token still valid?
	if user.IDDate.Add(30 * time.Minute).Before(time.Now()) {
		fmt.Fprint(response, "This password reset token has expired. Please request another one.")
		return
	}

	// Show a form for a new password.
	fmt.Fprint(response, "...")
}

We check if we have a token for the given user and whether it is still valid. For brevity, we don't show the generated HTML code here. But be sure to include the token again as it will be used to identify the user in the next step. You also may not want to display the user's email address here, in case someone got a hold of the token.

func changePassword(response http.ResponseWriter, request *http.Request) {
	// Find the user for this password reset token.
	token := request.FormValue("token")
	user, err := loadUserViaTempID(token)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	if user == nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "No user found for this token.")
		return
	}

	// Is the token still valid?
	if user.IDDate.Add(30 * time.Minute).Before(time.Now()) {
		fmt.Fprint(response, "This password reset token has expired. Please request another one.")
		return
	}

	// Check password integrity.
	password := request.FormValue("password")
	if result := sessions.ReasonablePassword(password); result != sessions.PasswordOK {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Invalid password")
		return
	}

	// Generate password hash.
	hash, err := bcrypt.GenerateFromPassword([]byte(password), 0)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	// Save new password.
	user.PasswordHash = hash
	user.IDDate = time.Unix(0, 0) // Invalidate token.
	user.Save()

	// Log the user out of all sessions.
	sessions.Logout(user.ID)

	// Redirect the user to the login page.
	http.Redirect(response, request, "/login", 302)
}

Here we check the token again, check the new password, and save its hash in the user struct. We also invalidate the token so it cannot be reused, log the user out of all sessions, and redirect them to the login page.

Additionally, you may want to send another email to the user, notifying them that their password has been changed.

Changing Email Addresses and/or Passwords

Sometimes, it's necessary to change user attributes. In those cases, the User object must be updated in all sessions. This is done with the sessions.RefreshUser() function. The following example illustrates how a user's email address can be changed.

func changeEmail(response http.ResponseWriter, request *http.Request) {
	// Get the session.
	session, err := sessions.Start(response, request, false)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	// If the user is not logged in, redirect them to the login page.
	if session == nil || session.User() == nil {
		http.Redirect(response, request, "/login", 302)
		return
	}
	user := session.User().(*MyUser)

	// To change an email address, we also require a valid password.
	password := request.FormValue("password")
	if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Wrong password")
		return
	}

	// We change the email address and send a verification email.
	user.Email = strings.ToLower(request.FormValue("email"))
	user.State = StateCreated // User needs to be validated again.

	// For code to send the validation email, see createUser().

	// You may also want to send a confirmation email to the old address.

	// Update this user in all sessions.
	if err := sessions.RefreshUser(user); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}

	fmt.Fprint(response, "Validation email has been sent")
}

To let a user change their email address, we require their password. We also send another verification email, for the same reasons as during the creation of a new user. Once the email address is updated (and their state set back to StateCreated to enfore the verification), we call sessions.RefreshUser() to update the User object in all current sessions.

Note that if you are using the User object in a goroutine or have it cached somewhere, it is your own responsibility to update it there, too.

Deleting Users

In the context of these examples, deleting a user is achieved by setting their state to StateExpired and calling sessions.Logout(). The user will not be able to log in anymore. A cron script can then clean up the database by deleting any users with the state StateExpired.

Multi-Factor Authentication

So far, we've authenticated users by letting them enter their email address and a password. Unfortunately, these days, emails and passwords are often compromised. A popular way to increase security is to implement multi-factor authentication (often two-factor authentication, or "2FA"). Once the user has entered a valid password, a separate piece of information is sent to the user using a different channel, often in the form of an SMS sent to the user's smartphone.

We can use session storage to maintain the state when a valid password was already entered but the second authentication has not yet taken place.

func logIn2FA(response http.ResponseWriter, request *http.Request) {
	email := strings.ToLower(request.FormValue("email"))
	password := request.FormValue("password")

	// Wait a second.
	time.Sleep(time.Second)

	// Load user.
	user, err := loadUserViaEmail(email)
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	if user == nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Wrong email and/or password")
		return
	}

	// Check password.
	if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "Wrong email and/or password")
		return
	}

	// Is the user in the correct state?
	if user.State != StateVerified {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "This account has not yet been verified.")
		// We may send another verification email at this point.
		return
	}

	// Generate a PIN code for the second authentication step.
	pin := make([]byte, 4)
	if _, err := rand.Read(pin); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	zero := []byte("0")
	for i := range pin {
		pin[i] = zero[0] + pin[i]%10
	}

	// Send a text message to the user's phone with the PIN code.
	message := fmt.Sprintf(`Your example.com PIN: %s
		Use this PIN only to log into example.com!`, string(pin))
	if err := sendSMS(user.PhoneNumber, message); err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}

	// Store PIN and the current state in the session object.
	session, err := sessions.Start(response, request, true)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}
	session.Set("userid", user.ID)
	session.Set("2fapin", string(pin))
	session.Set("2fatime", time.Now())

	fmt.Fprint(response, `Enter your pin: <form action="/complete2fa" method="post"><input name="pin"/></form>`)
}

Once the passward is verified, we generate a PIN code and send it to their phone. Note that there is a known phishing attack (described here) and we actually suggest following their advice to not send the PIN code directly but instead send a link to where the PIN code is displayed along with some more information. To keep these examples brief, we will show you the simple (and less secure) way.

We store the PIN in the user's session object, along with a timestamp, and the user ID. Session data is not stored in the browser so it cannot be spoofed. In the final step, we validate the pin and log the user in.

func complete2FA(response http.ResponseWriter, request *http.Request) {
	// Wait a second.
	time.Sleep(time.Second)

	// Get the session.
	session, err := sessions.Start(response, request, false)
	if err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}
	if session == nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, "No session found. Do you have cookies enabled?")
		return
	}

	// Verify the PIN.
	pin := request.FormValue("pin")
	storedPIN := session.Get("2fapin", nil)
	timestamp := session.Get("2fatime", nil)
	if storedPIN == nil || timestamp == nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, "No PIN found. Please try logging in again.")
		return
	}
	if timestamp.(time.Time).Add(10 * time.Minute).Before(time.Now()) {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, "Your PIN expired. Please try logging in again.")
		return
	}
	if storedPIN.(string) != pin {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, "Invalid PIN. Please try logging in again.")
		return
	}

	// Log the user in.
	userID := session.Get("userid", nil)
	if userID == nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, "User information not found. Please try logging in again.")
		return
	}
	user, err := loadUserViaID(userID.(string))
	if err != nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, err)
		return
	}
	if user == nil {
		response.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(response, "User not found. Please try logging in again.")
		return
	}
	if err := session.Login(user, false, response); err != nil {
		response.WriteHeader(http.StatusInternalServerError)
		fmt.Fprint(response, err)
		return
	}

	// Invalidate PIN.
	session.Set("2fatime", nil)

	fmt.Fprint(response, "You are logged in")
}

The session object has all the information we need. We check the timestamp of the PIN, validate the PIN itself, then load the user and attach them to the session.