Implement a ‘Change Password’ feature
Your goal in this exercise is to add the facility for an authenticated user to change their password, using a form which looks similar to this:
During this exercise you should make sure to:
- Ask the user for their current password and verify that it matches the hashed password in the
userstable (to confirm it is actually them making the request). - Hash their new password before updating the
userstable.
Step 1
Create two new routes and handlers:
GET /account/password/updatewhich maps to a newaccountPasswordUpdatehandler.POST /account/password/updatewhich maps to a newaccountPasswordUpdatePosthandler.
Both routes should be restricted to authenticated users only.
Step 2
Create a new ui/html/pages/password.tmpl file which contains the change password form. This form should:
- Have three fields:
currentPassword,newPasswordandnewPasswordConfirmation. POSTthe form data to/account/password/updatewhen submitted.- Display errors for each of the fields in the event of a validation error.
- Not re-display passwords in the event of a validation error.
Hint: You might want to use the work we did on the user signup form as a guide here.
Then update the cmd/web/handlers.go file to include a new accountPasswordUpdateForm struct that you can parse the form data into, and update the accountPasswordUpdate handler to display this empty form.
When you visit https://localhost:4000/account/password/update as an authenticated user it should look similar to this:
Step 3
Update the accountPasswordUpdatePost handler to carry out the following form validation checks, and re-display the form with the relevant error messages in the event of any failures.
- All three fields are required.
- The
newPasswordvalue must be at least 8 characters long. - The
newPasswordandnewPasswordConfirmationvalues must match.
Step 4
In your internal/models/users.go file create a new UserModel.PasswordUpdate() method with the following signature:
func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error
In this method:
- Retrieve the user details for the user with the ID given by the
idparameter from the database. - Check that the
currentPasswordvalue matches the hashed password for the user. If it doesn’t match, return anErrInvalidCredentialserror. - Otherwise, hash the
newPasswordvalue and update thehashed_passwordcolumn in theuserstable for the relevant user.
Also update the UserModelInterface interface type to include the PasswordUpdate() method that you’ve just created.
Step 5
Update the accountPasswordUpdatePost handler so that if the form is valid, it calls the UserModel.PasswordUpdate() method (remember, the user’s ID should be in the session data).
In the event of a models.ErrInvalidCredentials error, inform the user that they have entered the wrong value in the currentPassword form field. Otherwise, add a flash message to the user’s session saying that their password has been successfully changed and redirect them to their account page.
Step 6
Update the account to include a link to the change password form, similar to this:
Step 7
Try running the tests for the application. You should get a failure because the mocks.UserModel type no longer satisfies the interface specified in the models.UserModelInterface struct. Fix this by adding the appropriate PasswordUpdate() method to the mock and make sure that the tests pass.
Suggested code
Suggested code for step 1
package main ... func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) { // Some code will go here later... } func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) { // Some code will go here later... }
package main ... func (app *application) routes() http.Handler { mux := http.NewServeMux() mux.Handle("GET /static/", http.FileServerFS(ui.Files)) mux.HandleFunc("GET /ping", ping) dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate) mux.Handle("GET /{$}", dynamic.ThenFunc(app.home)) mux.Handle("GET /about", dynamic.ThenFunc(app.about)) 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 := dynamic.Append(app.requireAuthentication) mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate)) mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost)) mux.Handle("GET /account/view", protected.ThenFunc(app.accountView)) // Add the two new routes, restricted to authenticated users only. mux.Handle("GET /account/password/update", protected.ThenFunc(app.accountPasswordUpdate)) mux.Handle("POST /account/password/update", protected.ThenFunc(app.accountPasswordUpdatePost)) mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost)) standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) return standard.Then(mux) }
Suggested code for step 2
{{define "title"}}Change Password{{end}}
{{define "main"}}
<h2>Change Password</h2>
<form action='/account/password/update' method='POST' novalidate>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<div>
<label>Current password:</label>
{{with .Form.FieldErrors.currentPassword}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='currentPassword'>
</div>
<div>
<label>New password:</label>
{{with .Form.FieldErrors.newPassword}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='newPassword'>
</div>
<div>
<label>Confirm new password:</label>
{{with .Form.FieldErrors.newPasswordConfirmation}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='newPasswordConfirmation'>
</div>
<div>
<input type='submit' value='Change password'>
</div>
</form>
{{end}}
package main ... type accountPasswordUpdateForm struct { CurrentPassword string `form:"currentPassword"` NewPassword string `form:"newPassword"` NewPasswordConfirmation string `form:"newPasswordConfirmation"` validator.Validator `form:"-"` } func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r) data.Form = accountPasswordUpdateForm{} app.render(w, r, http.StatusOK, "password.tmpl", data) } ...
Suggested code for step 3
package main ... func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) { var form accountPasswordUpdateForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank") form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank") form.CheckField(validator.MinChars(form.NewPassword, 8), "newPassword", "This field must be at least 8 characters long") form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank") form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data) return } }
Suggested code for step 4
package models ... type UserModelInterface interface { Insert(name, email, password string) error Authenticate(email, password string) (int, error) Exists(id int) (bool, error) Get(id int) (User, error) PasswordUpdate(id int, currentPassword, newPassword string) error } ... func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error { var currentHashedPassword []byte stmt := "SELECT hashed_password FROM users WHERE id = ?" err := m.DB.QueryRow(stmt, id).Scan(¤tHashedPassword) if err != nil { return err } err = bcrypt.CompareHashAndPassword(currentHashedPassword, []byte(currentPassword)) if err != nil { if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { return ErrInvalidCredentials } else { return err } } newHashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12) if err != nil { return err } stmt = "UPDATE users SET hashed_password = ? WHERE id = ?" _, err = m.DB.Exec(stmt, string(newHashedPassword), id) return err }
Suggested code for step 5
package main ... func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) { var form accountPasswordUpdateForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank") form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank") form.CheckField(validator.MinChars(form.NewPassword, 8), "newPassword", "This field must be at least 8 characters long") form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank") form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match") if !form.Valid() { data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data) return } userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID") err = app.users.PasswordUpdate(userID, form.CurrentPassword, form.NewPassword) if err != nil { if errors.Is(err, models.ErrInvalidCredentials) { form.AddFieldError("currentPassword", "Current password is incorrect") data := app.newTemplateData(r) data.Form = form app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data) } else { app.serverError(w, r, err) } return } app.sessionManager.Put(r.Context(), "flash", "Your password has been updated!") http.Redirect(w, r, "/account/view", http.StatusSeeOther) }
Suggested code for step 6
{{define "title"}}Your Account{{end}}
{{define "main"}}
<h2>Your Account</h2>
{{with .User}}
<table>
<tr>
<th>Name</th>
<td>{{.Name}}</td>
</tr>
<tr>
<th>Email</th>
<td>{{.Email}}</td>
</tr>
<tr>
<th>Joined</th>
<td>{{humanDate .Created}}</td>
</tr>
<tr>
<!-- Add a link to the change password form -->
<th>Password</th>
<td><a href="/account/password/update">Change password</a></td>
</tr>
</table>
{{end }}
{{end}}
Suggested code for step 7
$ go test ./...
# snippetbox.alexedwards.net/cmd/web [snippetbox.alexedwards.net/cmd/web.test]
cmd/web/testutils_test.go:48:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type models.UserModelInterface in struct literal:
*mocks.UserModel does not implement models.UserModelInterface (missing PasswordUpdate method)
FAIL snippetbox.alexedwards.net/cmd/web [build failed]
ok snippetbox.alexedwards.net/internal/models 1.099s
? snippetbox.alexedwards.net/internal/models/mocks [no test files]
? snippetbox.alexedwards.net/internal/validator [no test files]
? snippetbox.alexedwards.net/ui [no test files]
FAIL
package mocks ... func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error { if id == 1 { if currentPassword != "pa$$word" { return models.ErrInvalidCredentials } return nil } return models.ErrNoRecord }
$ go test ./... ok snippetbox.alexedwards.net/cmd/web 0.026s ok snippetbox.alexedwards.net/internal/models (cached) ? snippetbox.alexedwards.net/internal/models/mocks [no test files] ? snippetbox.alexedwards.net/internal/validator [no test files] ? snippetbox.alexedwards.net/ui [no test files]