User authentication
Open up your handlers.go file and add placeholders for the five new handler functions as follows:
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Display a form for signing up a new user...") } func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Create a new user...") } func (app *application) userLogin(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Display a form for logging in a user...") } func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Authenticate and login the user...") } func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Logout the user...") }
Then when that’s done, let’s create the corresponding routes in the routes.go file:
package main import ( "net/http" "github.com/justinas/alice" ) func (app *application) routes() http.Handler { mux := http.NewServeMux() fileServer := http.FileServer(http.Dir("./ui/static/")) mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) dynamic := alice.New(app.sessionManager.LoadAndSave) mux.Handle("GET /{$}", dynamic.ThenFunc(app.home)) mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView)) mux.Handle("GET /snippet/create", dynamic.ThenFunc(app.snippetCreate)) mux.Handle("POST /snippet/create", dynamic.ThenFunc(app.snippetCreatePost)) // Add the five new routes, all of which use our `dynamic` middleware chain. mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup)) mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost)) mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin)) mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost)) mux.Handle("POST /user/logout", dynamic.ThenFunc(app.userLogoutPost)) standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) return standard.Then(mux) }
Finally, we’ll also need to update the nav.html partial to include navigation items for the new pages:
{{define "nav"}} <nav> <div> <a href="/">Home</a> <a href="/snippet/create">Create snippet</a> </div> <div> <a href="/user/signup">Signup</a> <a href="/user/login">Login</a> <form action="/user/logout" method="POST"> <button>Logout</button> </form> </div> </nav> {{end}}
Creating a user model
Now that the routes are set up, we need to create a new user database table and a database model to access it.
CREATE TABLE user ( id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, hashed_password CHAR(60) NOT NULL, created DATETIME NOT NULL ); ALTER TABLE user ADD CONSTRAINT uc_user_email UNIQUE (email);
The id field is an autoincrementing integer field and the primary key for the table. This means that the user ID values are guaranteed to be unique positive integers (1, 2, 3… etc).
The type of the hashed_password field is CHAR(60) . This is because we’ll be storing bcrypt hashes of the user passwords in the database — not the passwords themselves — and the hashes are always exactly 60 characters long.
Building the model in Go
First, open up the internal/models/errors.go file that you created earlier and define a couple of new error types:
package models import "errors" var ( ErrNoRecord = errors.New("models: no matching record found") ErrInvalidCredentials = errors.New("models: invalid credentials") ErrDuplicateEmail = errors.New("models: duplicate email") )
Then create a new file at internal/models/user.go and define a new User struct (to hold the data for a specific user) and a UserModel struc:
package models import ( "database/sql" "time" ) // User is the corresponding struct to the "user" table. type User struct { ID int Name string Email string HashedPassword []byte Created time.Time } // UserModel wraps a database connection pool. type UserModel struct { DB *sql.DB } // Insert inserts a record to the "user" table. func (m *UserModel) Insert(name, email, password string) error { return nil } // Authenticate verifies whether a user exists with the provided email and password. // This will return the relevant user ID if they do. func (m *UserModel) Authenticate(email, password string) (int, error) { return 0, nil } // Exists checks if a user exists with a specific ID. func (m *UserModel) Exists(id int) (bool, error) { return false, nil }
The final stage is to add a new field to our application struct so that we can make this model available to our handlers. Update the main.go file as follows:
type application struct { logger *slog.Logger snippet *models.SnippetModel user *models.UserModel templateCache map[string]*template.Template formDecoder *form.Decoder sessionManager *scs.SessionManager } func main() { ... app := &application{ logger: logger, snippet: &models.SnippetModel{DB: db}, user: &models.UserModel{DB: db}, templateCache: templateCache, formDecoder: formDecoder, sessionManager: sessionManager, } ... }
User signup and password encryption
Go ahead and create a new ui/html/pages/signup.html file containing the following markup for the signup form.
{{define "title"}}Signup{{end}} {{define "main"}} <form action="/user/signup" method="POST" novalidate> <div> <label>Name:</label> {{with .Form.FieldErrors.name}} <label class="error">{{.}}</label> {{end}} <input type="text" name="name" value="{{.Form.Name}}"> </div> <div> <label>Email:</label> {{with .Form.FieldErrors.email}} <label class="error">{{.}}</label> {{end}} <input type="email" name="email" value="{{.Form.Email}}"> </div> <div> <label>Password:</label> {{with .Form.FieldErrors.password}} <label class="error">{{.}}</label> {{end}} <input type="password" name="password"> </div> <div> <input type="submit" value="Signup"> </div> </form> {{end}}
Then let’s update our cmd/web/handlers.go file to include a new userSignupForm struct (which will represent and hold the form data), and hook it up to the userSignup handler.
type userSignupForm struct { Name string `form:"name"` Email string `form:"email"` Password string `form:"password"` validator.Validator `form:"-"` } func (app *application) userSignup(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r) data.Form = userSignupForm{} app.render(w, r, http.StatusOK, "signup.html", data) }
Validating the user input
When this form is submitted the data will end up being posted to the userSignupPost handler that we made earlier.
The first task of this handler will be to validate the data to make sure that it is sane and sensible before we insert it into the database. Specifically, we want to do four things:
1. Check that the provided name, email address and password are not blank.
2. Sanity check the format of the email address.
3. Ensure that the password is at least 8 characters long.
4. Make sure that the email address isn’t already in use.
We can cover the first three checks by heading back to our internal/validator/validator.go file and creating two helper new methods — MinChars() and Matches() — along with a regular expression for sanity checking an email address.
package validator import ( "regexp" "slices" "strings" "unicode/utf8" ) // Use the regexp.MustCompile() function to parse a regular expression pattern for sanity checking // the format of an email address. This returns a pointer to a 'compiled' regexp.Regexp type, or // panics in the event of an error. Parsing this pattern once at startup and storing the compiled // *regexp.Regexp in a variable is more performant than re-parsing the pattern each time we need // it. var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
...
// MinChars returns true if a value contains at least n characters. func MinChars(value string, n int) bool { return utf8.RuneCountInString(value) >= n } ... // Match returns true if a value matches a provided compiled regular expression pattern. func Match(value string, rx *regexp.Regexp) bool { return rx.MatchString(value) }
There are a couple of things about the EmailRX regular expression pattern I want to quickly mention:
- The pattern we’re using is the one currently recommended by the W3C and Web Hypertext Application Technology Working Group for validating email addresses. For more information about this pattern, see here.
- Because the EmailRX regexp pattern is written as an interpreted string literal, we need to double-escape special characters in the regexp with \\ for it to work correctly (we can’t use a raw string literal because the pattern contains a back quote character). If you’re not familiar with the difference between string literal forms, then this section of the Go spec is worth a read.
Head over to your handlers.go file and add some code to process the form and run the validation checks like so:
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) { var form userSignupForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.CheckField(validator.NotEmpty(form.Name), "name", "This field cannot be empty.") form.CheckField(validator.NotEmpty(form.Email), "email", "This field cannot be empty.") form.CheckField(validator.Match(form.Email, validator.EmailRX), "email", "This field must be a valid email address.") form.CheckField(validator.NotEmpty(form.Password), "password", "This field cannot be empty.") form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long.") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "signup.html", data) return } // Otherwise send the placeholder response (for now!). fmt.Fprintln(w, "Create a new user...") }
All that remains now is the fourth validation check: make sure that the email address isn’t already in use. This is a bit trickier to deal with.
A brief introduction to bcrypt
If your database is ever compromised by an attacker, it’s hugely important that it doesn’t contain the plain-text versions of your users’ passwords.
It’s good practice — well, essential, really — to store a one-way hash of the password, derived with a computationally expensive key-derivation function such as Argon2, scrypt or bcrypt. Go has implementations of all 3 algorithms in the golang.org/x/crypto package.
However a plus-point of the bcrypt implementation specifically is that it includes helper functions specifically designed for hashing and checking passwords, and that’s what we’ll use here.
zzh@ZZHPC:/zdata/Github/snippetbox$ go get golang.org/x/crypto/bcrypt go: downloading golang.org/x/crypto v0.27.0 go: added golang.org/x/crypto v0.27.0
There are two functions that we’ll use in this book. The first is the bcrypt.GenerateFromPassword() function which lets us create a hash of a given plain-text password like so:
hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)
This function will return a 60-character long hash which looks a bit like this:
$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG
The second parameter that we pass to bcrypt.GenerateFromPassword() indicates the cost, which is represented by an integer between 4 and 31. The example above uses a cost of 12, which means that 4096 (2^12) bcrypt iterations will be used to generate the password hash.
The higher the cost, the more expensive the hash will be for an attacker to crack (which is a good thing). But a higher cost also means that our application needs to do more work to create the password hash when a user signs up — and that means increased resource use by your application and additional latency for the end user. So choosing an appropriate cost value is a balancing act. A cost of 12 is a reasonable minimum, but if possible you should carry out load testing, and if you can set the cost higher without adversely affecting user experience then you should.
On the flip side, we can check that a plain-text password matches a particular hash using the bcrypt.CompareHashAndPassword() function like so:
hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG") err := bcrypt.CompareHashAndPassword(hash, []byte("my plain text password"))
The bcrypt.CompareHashAndPassword() function will return nil if the plain-text password matches a particular hash, or an error if they don’t match.
Storing the user details
The next stage of our build is to update the UserModel.Insert() method so that it creates a new record in our users table containing the validated name, email and hashed password.
This will be interesting for two reasons: first we want to store the bcrypt hash of the password (not the password itself) and second, we also need to manage the potential error caused by a duplicate email violating the UNIQUE constraint that we added to the table.
All errors returned by MySQL have a particular code, which we can use to triage what has caused the error (a full list of the MySQL error codes and descriptions can be found here). In the case of a duplicate email, the error code used will be 1062 (ER_DUP_ENTRY) .
Open the internal/models/user.go file and update it to include the following code:
// UserModel wraps a database connection pool. type UserModel struct { DB *sql.DB } // Insert inserts a record to the "user" table. func (m *UserModel) Insert(name, email, password string) error { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return err } stmt := `INSERT INTO user (name, email, hashed_password, created) VALUES(?, ?, ?, UTC_TIMESTAMP())` _, err = m.DB.Exec(stmt, name, email, string(hashedPassword)) if err != nil { // If this returns an error, we use the errors.As() function to check whether the rror has // the type *mysql.MySQLError. If it does, the error will be assigned to the mySQLError // variable. We can check whether or not the error relates to our uc_user_email constraint // by checking if the error code equals 1062 (ER_DUP_ENTRY) and the contents of the error // message string. If it does, we return an ErrDuplicateEmail error. var mySQLError *mysql.MySQLError if errors.As(err, &mySQLError) { if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "uc_user_email") { return ErrDuplicateEmail } } return err } return nil }
We can then finish this all off by updating the userSignup handler like so:
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) { var form userSignupForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.CheckField(validator.NotEmpty(form.Name), "name", "This field cannot be empty.") form.CheckField(validator.NotEmpty(form.Email), "email", "This field cannot be empty.") form.CheckField(validator.Match(form.Email, validator.EmailRX), "email", "This field must be a valid email address.") form.CheckField(validator.NotEmpty(form.Password), "password", "This field cannot be empty.") form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long.") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "signup.html", data) return } // Try to create a new user record in the database. If the email already exists then add an // error message to the form and re-display it. err = app.user.Insert(form.Name, form.Email, form.Password) if err != nil { if errors.Is(err, models.ErrDuplicateEmail) { form.AddFieldError("email", "Email address is already in use.") data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "signup.html", data) } else { app.serverError(w, r, err) } return } // Otherwise add a confirmation flash message to the session confirming that their signup worked. app.sessionManager.Put(r.Context(), "flash", "Your signup was successful. Please log in.") // And redirect the user to the login page. http.Redirect(w, r, "/user/login", http.StatusSeeOther) }
Using database bcrypt implementations
Some databases provide built-in functions that you can use for password hashing and verification instead of implementing your own in Go, like we have in the code above.
But it’s probably a good idea to avoid using these for two reasons:
- They tend to be vulnerable to side-channel timing attacks due to string comparison time not being constant, at least in PostgreSQL and MySQL.
- Unless you’re very careful, sending a plain-text password to your database risks the password being accidentally recorded in one of your database logs. A couple of high-profile examples of passwords being accidently recorded in logs were the GitHub and Twitter incidents in 2018.
Alternatives for checking email duplicates
I understand that the code in our UserModel.Insert() method isn’t very pretty, and that checking the error returned by MySQL feels a bit flaky. What if future versions of MySQL change their error numbers? Or the format of their error messages?
An alternative (but also imperfect) option would be to add an UserModel.EmailTaken() method to our model which checks to see if a user with a specific email already exists. We could call this before we try to insert a new record, and add a validation error message to the form as appropriate.
However, this would introduce a race condition to our application. If two users try to sign up with the same email address at exactly the same time, both submissions will pass the validation check but ultimately only one INSERT into the MySQL database will succeed. The other will violate our UNIQUE constraint and the user would end up receiving a 500 Internal Server Error response.
The outcome of this particular race condition is fairly benign, and some people would advise you to simply not worry about it. But thinking critically about your application logic and writing code which avoids race conditions is a good habit to get into, and where there’s a viable alternative — like there is in this case — it’s better to avoid shipping with known race conditions in your codebase.
User login
Before we get into the main part of this work, let’s quickly revisit the internal/validator package that we made earlier and update it to support validation errors which aren’t associated with one specific form field.
We’ll use this later in the chapter to show the user a generic “your email address or password is wrong” message if their login fails, as this considered more secure than explicitly indicating why the login failed.
Please go ahead and update your internal/validator/validator.go file like so:
// Validator contains structures that hold validation errors. type Validator struct { NonFieldErrors []string // Holds validation errors which are not related to a specific form field. FieldErrors map[string]string // Holds validation errors for form fields. } // Valid returns true if both NonFieldErrors and FieldErrors are empty. func (v *Validator) Valid() bool { return len(v.NonFieldErrors) == 0 && len(v.FieldErrors) == 0 } // AddNonFieldError adds error messages to the NonFieldErrors slice. func (v *Validator) AddNonFieldError(message string) { v.NonFieldErrors = append(v.NonFieldErrors, message) }
Next let’s create a new ui/html/pages/login.html template containing the markup for our login page. We’ll follow the same pattern for showing validation errors and re-displaying data that we used for our signup page.
{{define "title"}}Login{{end}} {{define "main"}} <form action="/user/login" method="POST" novalidate> <!-- Here we are looping over the NonFieldErrors and displaying them, if any exist. --> {{range .Form.NonFieldErrors}} <div class="error">{{.}}</div> {{end}} <div> <label>Email:</label> {{with .Form.FieldErrors.email}} <label class="error">{{.}}</label> {{end}} <input type="email" name="email" value="{{.Form.Email}}"> </div> <div> <label>Password:</label> {{with .Form.FieldErrors.password}} <label class="error">{{.}}</label> {{end}} <input type="password" name="password"> </div> <div> <input type="submit" value="Login"> </div> </form> {{end}}
Then let’s head to our cmd/web/handlers.go file and create a new userLoginForm struct (to represent and hold the form data), and adapt our userLogin handler to render the login page.
type userLoginForm struct { Email string `form:"email"` Password string `form:"password"` validator.Validator `form:"-"` } func (app *application) userLogin(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r) data.Form = userLoginForm{} app.render(w, r, http.StatusOK, "login.html", data) }
Verifying the user details
The next step is the interesting part: how do we verify that the email and password submitted by a user are correct?
The core part of this verification logic will take place in the UserModel.Authenticate() method of our user model. Specifically, we’ll need it to do two things:
1. First it should retrieve the hashed password associated with the email address from our MySQL users table. If the email doesn’t exist in the database, we will return the ErrInvalidCredentials error that we made earlier.
2. Otherwise, we want to compare the hashed password from the users table with the plain-text password that the user provided when logging in. If they don’t match, we want to return the ErrInvalidCredentials error again. But if they do match, we want to return the user’s id value from the database.
Let’s do exactly that. Go ahead and add the following code to your internal/models/user.go file:
func (m *UserModel) Authenticate(email, password string) (int, error) { var id int var hashedPassword []byte stmt := "SELECT id, hashed_password FROM user WHERE email = ?" err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword) if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, ErrInvalidCredentials } else { return 0, err } } err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)) if err != nil { if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { return 0, ErrInvalidCredentials } else { return 0, err } } return id, nil }
If the login details are valid, we then want to add the user’s id to their session data so that — for future requests — we know that they have authenticated successfully and which user they are.
Head over to your handlers.go file and update it as follows:
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) { var form userLoginForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.CheckField(validator.NotEmpty(form.Email), "email", "This field cannot be empty.") form.CheckField(validator.Match(form.Email, validator.EmailRX), "email", "This field must be a valid email address.") form.CheckField(validator.NotEmpty(form.Password), "password", "This field cannot be blank.") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "login.html", data) return } // Check whether the credentials are valid. If they're not, add a generic non-field error // message and re-display the login page. id, err := app.user.Authenticate(form.Email, form.Password) if err != nil { if errors.Is(err, models.ErrInvalidCredentials) { form.AddNonFieldError("Email or password is incorrect!") data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "login.html", data) } else { app.serverError(w, r, err) } return } // Use the RenewToken() method on the current session to change the session ID. It's a good // practice to generate a new session ID when the authentication state or privilage level // changes for the user (e.g. login and logout operations). err = app.sessionManager.RenewToken(r.Context()) if err != nil { app.serverError(w, r, err) return } // Add the ID of the current user to the session, so that they are now 'logged in'. app.sessionManager.Put(r.Context(), "authenticatedUserID", id) // Redirect the user to the create snippet page. http.Redirect(w, r, "/snippet/create", http.StatusSeeOther) }
Note: The SessionManager.RenewToken() method that we’re using in the code above will change the ID of the current user’s session but retain any data associated with the session. It’s good practice to do this before login to mitigate the risk of a session fixation attack. For more background and information on this, please see the OWASP Session Management Cheat Sheet.
User logout
This brings us nicely to logging out a user. Implementing the user logout is straightforward in comparison to the signup and login — essentially all we need to do is remove the "authenticatedUserID" value from their session.
At the same time it’s good practice to renew the session ID again, and we’ll also add a flash message to the session data to confirm to the user that they’ve been logged out.
Let’s update the userLogoutPost handler to do exactly that.
func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) { // Use the RenewToken() method on the current session to change the session ID again. err := app.sessionManager.RenewToken(r.Context()) if err != nil { app.serverError(w, r, err) return } // Remove the authenticatedUserID from the session data so that the user is 'logged out'. app.sessionManager.Remove(r.Context(), "authenticatedUserID") // Add a flash message to the session to confirm to the user that they've been logged out. app.sessionManager.Put(r.Context(), "flash", "You've logged out successfully!") // Redirect the user to the application home page. http.Redirect(w, r, "/", http.StatusSeeOther) }
User authorization
Being able to authenticate the users of our application is all well and good, but now we need to do something useful with that information. In this chapter we’ll introduce some authorization checks so that:
1. Only authenticated (i.e. logged in) users can create a new snippet; and
2. The contents of the navigation bar changes depending on whether a user is authenticated (logged in) or not. Specifically:
- Authenticated users should see links to ‘Home’, ‘Create snippet’ and ‘Logout’.
- Unauthenticated users should see links to ‘Home’, ‘Signup’ and ‘Login’.
As I mentioned briefly in the previous chapter, we can check whether a request is being made by an authenticated user or not by checking for the existence of an "authenticatedUserID" value in their session data.
So let’s start with that. Open the cmd/web/helpers.go file and add an isAuthenticated() helper function to return the authentication status like so:
func (app *application) isAuthenticated(r *http.Request) bool { return app.sessionManager.Exists(r.Context(), "authenticatedUserID") }
That’s neat. We can now check whether or not the request is coming from an authenticated (logged in) user by simply calling this isAuthenticated() helper.
The next step is to find a way to pass this information to our HTML templates, so that we can toggle the contents of the navigation bar appropriately.
There are two parts to this. First, we’ll need to add a new IsAuthenticated field to our templateData struct:
type templateData struct { CurrentYear int Snippet models.Snippet Snippets []models.Snippet Form any Flash string IsAuthenticated bool }
And the second step is to update our newTemplateData() helper so that this information is automatically added to the templateData struct every time we render a template. Like so:
func (app *application) newTemplateData(r *http.Request) templateData { return templateData{ CurrentYear: time.Now().Year(), Flash: app.sessionManager.PopString(r.Context(), "flash"), IsAuthenticated: app.isAuthenticated(r), } }
Once that’s done, we can update the ui/html/partials/nav.html file to toggle the navigation links using the {{if .IsAuthenticated}} action like so:
{{define "nav"}} <nav> <div> <a href="/">Home</a> {{if .IsAuthenticated}} <a href="/snippet/create">Create snippet</a> {{end}} </div> <div> {{if .IsAuthenticated}} <form action="/user/logout" method="POST"> <button>Logout</button> </form> {{else}} <a href="/user/signup">Signup</a> <a href="/user/login">Login</a> {{end}} </div> </nav> {{end}}
Restricting access
As it stands, we’re hiding the ‘Create snippet’ navigation link for any user that isn’t logged in. But an unauthenticated user could still create a new snippet by visiting the https://localhost:4000/snippet/create page directly.
Let’s fix that, so that if an unauthenticated user tries to visit any routes with the URL path /snippet/create they are redirected to /user/login instead.
The simplest way to do this is via some middleware. Open the cmd/web/middleware.go file and create a new requireAuthentication() middleware function:
func (app *application) requireAuthentication(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // If the user is not authenticated, redirect them to the login page and return from the // middleware chain so that no subsequent handlers in the chain are executed. if !app.isAuthenticated(r) { http.Redirect(w, r, "/user/login", http.StatusSeeOther) return } // Otherwise set the "Cache-Control: no-store" header so that pages require authtication // are not stored in the user's browser cache (or other intermediary cache). w.Header().Add("Cache-Control", "no-store") next.ServeHTTP(w, r) }) }
We can now add this middleware to our cmd/web/routes.go file to protect specific routes.
In our case we’ll want to protect the GET /snippet/create and POST /snippet/create routes. And there’s not much point logging out a user if they’re not logged in, so it makes sense to use it on the POST /user/logout route as well.
To help with this, let’s rearrange our application routes into two ‘groups’.
The first group will contain our ‘unprotected’ routes and use our existing dynamic middleware chain. The second group will contain our ‘protected’ routes and will use a new protected middleware chain — consisting of the dynamic middleware chain plus our new requireAuthentication() middleware.
package main import ( "net/http" "github.com/justinas/alice" ) func (app *application) routes() http.Handler { mux := http.NewServeMux() fileServer := http.FileServer(http.Dir("./ui/static/")) mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) // Unprotected routes using the "dynamic" middleware chain. dynamic := alice.New(app.sessionManager.LoadAndSave) mux.Handle("GET /{$}", dynamic.ThenFunc(app.home)) mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView)) mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup)) mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost)) mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin)) mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost)) // Protected (authenticated-only) routes using the "protected" middleware chain which includes // the requireAuthentication middleware. protected := dynamic.Append(app.requireAuthentication) mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate)) mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost)) mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost)) standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) return standard.Then(mux) }
CSRF protection
In this chapter we’ll look at how to protect our application from cross-site request forgery (CSRF) attacks.
If you’re not familiar with the principles of CSRF, it’s a type of attack where a malicious third-party website sends state-changing HTTP requests to your website. A great explanation of the basic CSRF attack can be found here.
In our application, the main risk is this:
- A user logs into our application. Our session cookie is set to persist for 12 hours, so they will remain logged in even if they navigate away from the application.
- The user then goes to another website, which contains some malicious code that sends a cross-site request to our POST /snippet/create endpoint to add a new snippet to our database. The user’s session cookie for our application will be sent along with this request.
- Because the request includes the session cookie, our application will interpret the request as coming from a logged-in user and it will process the request with that user’s privileges. So completely unknown to the user, a new snippet will be added to our database.
As well as ‘traditional’ CSRF attacks like the above (where a request is processed with a logged-in user’s privileges) your application may also be at risk from login and logout CSRF attacks.
SameSite cookies
One mitigation that we can take to prevent CSRF attacks is to make sure that the SameSite attribute is appropriately set on our session cookie.
By default the alexedwards/scs package that we’re using always sets SameSite=Lax on the session cookie. This means that the session cookie won’t be sent by the user’s browser for any cross-site requests with the HTTP methods POST , PUT or DELETE .
So long as our application uses the POST method for any state-changing HTTP requests (like we are for our login, signup, logout and create snippet form submissions), it means that the session cookie won’t be sent for these requests if they come from another website — thereby preventing the CSRF attack.
However, the SameSite attribute is still relatively new and only fully supported by 96% of browsers worldwide. So, although it’s something that we can (and should) use as a defensive measure, we can’t rely on it for all users.
Token-based mitigation
To mitigate the risk of CSRF for all users we’ll also need to implement some form of token check. Like session management and password hashing, when it comes to this there’s a lot that you can get wrong… so it’s probably safest to use a tried-and-tested third-party package instead of rolling your own implementation.
The two most popular packages for stopping CSRF attacks in Go web applications are gorilla/csrf and justinas/nosurf . They both do roughly the same thing, using the double-submit cookie pattern to prevent attacks. In this pattern a random CSRF token is generated and sent to the user in a CSRF cookie. This CSRF token is then added to a hidden field in each HTML form that is potentially vulnerable to CSRF. When the form is submitted, both packages use some middleware to check that the hidden field value and cookie value match.
Out of the two packages, we’ll opt to use justinas/nosurf . I prefer it primarily because it’s self-contained and doesn’t have any additional dependencies.
zzh@ZZHPC:/zdata/Github/snippetbox$ go get github.com/justinas/nosurf go: downloading github.com/justinas/nosurf v1.1.1 go: added github.com/justinas/nosurf v1.1.1
Using the nosurf package
To use justinas/nosurf , open up your cmd/web/middleware.go file and create a new noSurf() middleware function like so:
// Create a noSurf middleware function which uses a customized CSRF cookie with the Secure, Path // and HttpOnly attributes set. func noSurf(next http.Handler) http.Handler { csrfHandler := nosurf.New(next) csrfHandler.SetBaseCookie(http.Cookie{ HttpOnly: true, Path: "/", Secure: true, }) return csrfHandler }
One of the forms that we need to protect from CSRF attacks is our logout form, which is included in our nav.tmpl partial and could potentially appear on any page of our application. So, because of this, we need to use our noSurf() middleware on all of our application routes (apart from GET /static/ ).
So, let’s update the cmd/web/routes.go file to add this noSurf() middleware to the dynamic middleware chain that we made earlier:
package main import ( "net/http" "github.com/justinas/alice" ) func (app *application) routes() http.Handler { mux := http.NewServeMux() fileServer := http.FileServer(http.Dir("./ui/static/")) mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) // Unprotected routes using the "dynamic" middleware chain. // Use the noSurf middleware on all our "dynamic" routes. dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf) mux.Handle("GET /{$}", dynamic.ThenFunc(app.home)) mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView)) mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup)) mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost)) mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin)) mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost)) // Protected (authenticated-only) routes using the "protected" middleware chain which includes // the requireAuthentication middleware. protected := dynamic.Append(app.requireAuthentication) mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate)) mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost)) mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost)) standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) return standard.Then(mux) }
At this point, you might like to fire up the application and try submitting one of the forms. When you do, the request should be intercepted by the noSurf() middleware and you should receive a 400 Bad Request response.
To make the form submissions work, we need to use the nosurf.Token() function to get the CSRF token and add it to a hidden csrf_token field in each of our forms. So the next step is to add a new CSRFToken field to our templateData struct:
type templateData struct { CurrentYear int Snippet models.Snippet Snippets []models.Snippet Form any Flash string IsAuthenticated bool CSRFToken string }
And because the logout form can potentially appear on every page, it makes sense to add the CSRF token to the template data automatically via our newTemplateData() helper. This will mean that it will be available to our templates each time we render a page.
func (app *application) newTemplateData(r *http.Request) templateData { return templateData{ CurrentYear: time.Now().Year(), Flash: app.sessionManager.PopString(r.Context(), "flash"), IsAuthenticated: app.isAuthenticated(r), CSRFToken: nosurf.Token(r), } }
Finally, we need to update all the forms in our application to include this CSRF token in a hidden field.
... <form action="/snippet/create" method="POST"> <!-- Include the CSRF token --> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> ...
... <form action="/user/login" method="POST" novalidate> <!-- Include the CSRF token --> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> ...
... <form action="/user/signup" method="POST" novalidate> <!-- Include the CSRF token --> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> ...
{{define "nav"}} <nav> <div> <a href="/">Home</a> {{if .IsAuthenticated}} <a href="/snippet/create">Create snippet</a> {{end}} </div> <div> {{if .IsAuthenticated}} <form action="/user/logout" method="POST"> <!-- Include the CSRF token --> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <button>Logout</button> </form> {{else}} <a href="/user/signup">Signup</a> <a href="/user/login">Login</a> {{end}} </div> </nav> {{end}}
Go ahead and run the application again, then view source of one of the forms. You should see that it now has a CSRF token included in a hidden field, like so.
And if you try submitting the forms, it should now work correctly again.
SameSite ‘Strict’ setting
If you want, you can change the session cookie to use the SameSite=Strict setting instead of (the default) SameSite=Lax . Like this:
sessionManager := scs.New()
sessionManager.Cookie.SameSite = http.SameSiteStrictMode
But it’s important to be aware that using SameSite=Strict will block the session cookie being sent by the user’s browser for all cross-site usage — including safe requests with HTTP methods like GET and HEAD .
While that might sound even safer (and it is!) the downside is that the session cookie won’t be sent when a user clicks on a link to your application from another website. In turn, that means that your application would initially treat the user as ‘not logged in’ even if they have an active session containing their "authenticatedUserID" value.
So if your application will potentially have other websites linking to it (or links to it shared in emails or private messaging services), then SameSite=Lax is generally the more appropriate setting.
SameSite cookies and TLS 1.3
Earlier I said that we can’t solely rely on the SameSite cookie attribute to prevent CSRF attacks, because it isn’t fully supported by all browsers.
But there is an exception to this rule, due to the fact that no browser exists which supports TLS 1.3 and does not support SameSite cookies.
In other words, if you were to make TLS 1.3 the minimum supported version in the TLS config for your server, then all browsers able to use your application will support SameSite cookies.
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
So long as you only allow HTTPS requests to your application and enforce TLS 1.3 as the minimum TLS version, you don’t need to make any additional mitigation against CSRF attacks (like using the justinas/nosurf package). Just make sure that you always:
- Set SameSite=Lax or SameSite=Strict on the session cookie; and
- Use the POST , PUT or DELETE HTTP methods for any state-changing requests.