diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index dbc9dec..a217efc 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -25,10 +25,11 @@ func (app *application) routes() http.Handler { router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) + router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler) + + router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) + router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler) - router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", - app.createAuthenticationTokenHandler) - // Register a new GET /debug/vars endpoint pointing to the expvar handler. router.Handler(http.MethodGet, "/debug/vars", expvar.Handler()) return app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router))))) diff --git a/projects/greenlight/cmd/api/tokens.go b/projects/greenlight/cmd/api/tokens.go index ea4664e..0d698e0 100644 --- a/projects/greenlight/cmd/api/tokens.go +++ b/projects/greenlight/cmd/api/tokens.go @@ -75,3 +75,76 @@ func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, app.serverErrorResponse(w, r, err) } } + +// Generate a password reset token and send it to the user's email address. +func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) { + // Parse and validate the user's email address. + + var input struct { + Email string `json:"email"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validator.New() + + if data.ValidateEmail(v, input.Email); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Try to retrieve the corresponding user record for the email address. If it can't + // be found, return an error message to the client. + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + v.AddError("email", "no matching email address found") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + // Return an error message if the user is not activated. + if !user.Activated { + v.AddError("email", "user account must be activated") + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Otherwise, create a new password reset token with a 45-minute expiry time. + token, err := app.models.Tokens.New(user.ID, 45*time.Minute, data.ScopePasswordReset) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Email the user with their password reset token. + app.background(func() { + data := map[string]any{ + "passwordResetToken": token.Plaintext, + } + + // Since email addresses MAY be case sensitive, notice that we are sending this + // email using the address stored in our database for the user --- not to the + // input.Email address provided by the client in this request. + err = app.mailer.Send(user.Email, "token_password_reset.tmpl", data) + if err != nil { + app.logger.PrintError(err, nil) + } + }) + + // Send a 202 Accepted response and confirmation message to the client. + env := envelope{"message": "an email will be sent to you containing password reset instructions"} + + err = app.writeJSON(w, http.StatusAccepted, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/projects/greenlight/cmd/api/users.go b/projects/greenlight/cmd/api/users.go index a81c403..b051828 100644 --- a/projects/greenlight/cmd/api/users.go +++ b/projects/greenlight/cmd/api/users.go @@ -166,3 +166,77 @@ func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Reque app.serverErrorResponse(w, r, err) } } + +// Verify the password reset token and set a new password for the user. +func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) { + // Parse and validate the user's new password and password reset token. + var input struct { + Password string `json:"password"` + TokenPlaintext string `json:"token"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validator.New() + + data.ValidatePasswordPlaintext(v, input.Password) + data.ValidateTokenPlaintext(v, input.TokenPlaintext) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Retrieve the details of the user associated with the password reset token, + // returning an error message if no matching record was found. + user, err := app.models.Users.GetForToken(data.ScopePasswordReset, input.TokenPlaintext) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + v.AddError("token", "invalid or expired password reset token") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + // Set the new password for the user. + err = user.Password.Set(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Save the updated user record in our database, checking for any edit conflicts as + // normal. + err = app.models.Users.Update(user) + if err != nil { + switch { + case errors.Is(err, data.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + // If everything was successful, then delete all password reset tokens for the user. + err = app.models.Tokens.DeleteAllForUser(data.ScopePasswordReset, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Send the user a confirmation message. + env := envelope{"message": "your password was successfully reset"} + + err = app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/projects/greenlight/internal/data/tokens.go b/projects/greenlight/internal/data/tokens.go index bc19a7a..70528dc 100644 --- a/projects/greenlight/internal/data/tokens.go +++ b/projects/greenlight/internal/data/tokens.go @@ -16,7 +16,7 @@ import ( const ( ScopeActivation = "activation" ScopeAuthentication = "authentication" // Include a new authentication scope. - + ScopePasswordReset = "password-reset" ) // Define a Token struct to hold the data for an individual token. This includes the diff --git a/projects/greenlight/internal/mailer/templates/token_password_reset.tmpl b/projects/greenlight/internal/mailer/templates/token_password_reset.tmpl new file mode 100644 index 0000000..06c5fcc --- /dev/null +++ b/projects/greenlight/internal/mailer/templates/token_password_reset.tmpl @@ -0,0 +1,38 @@ +{{define "subject"}}Reset your Greenlight password{{end}} + +{{define "plainBody"}} +Hi, + +Please send a `PUT /v1/users/password` request with the following JSON body to set a new password: + +{"password": "your new password", "token": "{{.passwordResetToken}}"} + +Please note that this is a one-time use token and it will expire in 45 minutes. If you need +another token please make a `POST /v1/tokens/password-reset` request. + +Thanks, + +The Greenlight Team +{{end}} + +{{define "htmlBody"}} + + +
+ + + + +Hi,
+Please send a PUT /v1/users/password request with the following JSON body to set a new password:
+ {"password": "your new password", "token": "{{.passwordResetToken}}"}
+
+ Please note that this is a one-time use token and it will expire in 45 minutes.
+ If you need another token please make a POST /v1/tokens/password-reset request.
Thanks,
+The Greenlight Team
+ + +{{end}} +