feat: token creation, email templace change, activate user endpoint
This commit is contained in:
@@ -24,7 +24,7 @@ func (app *application) routes() http.Handler {
|
|||||||
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
|
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
|
||||||
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
|
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
|
||||||
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
|
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
|
||||||
|
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
|
||||||
// Wrap the router with the rateLimit() middleware.
|
// Wrap the router with the rateLimit() middleware.
|
||||||
return app.recoverPanic(app.rateLimit(router))
|
return app.recoverPanic(app.rateLimit(router))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"greenlight.debuggingjon.dev/internal/data"
|
"greenlight.debuggingjon.dev/internal/data"
|
||||||
"greenlight.debuggingjon.dev/internal/validator"
|
"greenlight.debuggingjon.dev/internal/validator"
|
||||||
@@ -50,13 +51,9 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the user data into the database.
|
|
||||||
err = app.models.Users.Insert(user)
|
err = app.models.Users.Insert(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
// If we get a ErrDuplicateEmail error, use the v.AddError() method to manually
|
|
||||||
// add a message to the validator instance, and then call our
|
|
||||||
// failedValidationResponse() helper.
|
|
||||||
case errors.Is(err, data.ErrDuplicateEmail):
|
case errors.Is(err, data.ErrDuplicateEmail):
|
||||||
v.AddError("email", "a user with this email address already exists")
|
v.AddError("email", "a user with this email address already exists")
|
||||||
app.failedValidationResponse(w, r, v.Errors)
|
app.failedValidationResponse(w, r, v.Errors)
|
||||||
@@ -66,20 +63,99 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the background helper to execute an anonymous function that sends the welcome
|
// After the user record has been created in the database, generate a new activation
|
||||||
// email.
|
// token for the user.
|
||||||
|
token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation)
|
||||||
|
if err != nil {
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
app.background(func() {
|
app.background(func() {
|
||||||
err = app.mailer.Send(user.Email, "user_welcome.tmpl", user)
|
// As there are now multiple pieces of data that we want to pass to our email
|
||||||
|
// templates, we create a map to act as a 'holding structure' for the data. This
|
||||||
|
// contains the plaintext version of the activation token for the user, along
|
||||||
|
// with their ID.
|
||||||
|
data := map[string]any{
|
||||||
|
"activationToken": token.Plaintext,
|
||||||
|
"userID": user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the welcome email, passing in the map above as dynamic data.
|
||||||
|
err = app.mailer.Send(user.Email, "user_welcome.tmpl", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.logger.PrintError(err, nil)
|
app.logger.PrintError(err, nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Note that we also change this to send the client a 202 Accepted status code.
|
|
||||||
// This status code indicates that the request has been accepted for processing, but
|
|
||||||
// the processing has not been completed.
|
|
||||||
err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
|
err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.serverErrorResponse(w, r, err)
|
app.serverErrorResponse(w, r, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse the plaintext activation token from the request body.
|
||||||
|
var input struct {
|
||||||
|
TokenPlaintext string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := app.readJSON(w, r, &input)
|
||||||
|
if err != nil {
|
||||||
|
app.badRequestResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the plaintext token provided by the client.
|
||||||
|
v := validator.New()
|
||||||
|
|
||||||
|
if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
|
||||||
|
app.failedValidationResponse(w, r, v.Errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the details of the user associated with the token using the
|
||||||
|
// GetForToken() method (which we will create in a minute). If no matching record
|
||||||
|
// is found, then we let the client know that the token they provided is not valid.
|
||||||
|
user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, data.ErrRecordNotFound):
|
||||||
|
v.AddError("token", "invalid or expired activation token")
|
||||||
|
app.failedValidationResponse(w, r, v.Errors)
|
||||||
|
default:
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user's activation status.
|
||||||
|
user.Activated = true
|
||||||
|
|
||||||
|
// Save the updated user record in our database, checking for any edit conflicts in
|
||||||
|
// the same way that we did for our movie records.
|
||||||
|
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 went successfully, then we delete all activation tokens for the
|
||||||
|
// user.
|
||||||
|
err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the updated user details to the client in a JSON response.
|
||||||
|
err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
|
||||||
|
if err != nil {
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
info:
|
||||||
|
name: Activate User
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: "{{URL}}/v1/users/activated"
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"token": "PRI372IRUNEGH65VUU4KEM4RYQ"
|
||||||
|
}
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
@@ -10,8 +10,8 @@ http:
|
|||||||
type: json
|
type: json
|
||||||
data: |-
|
data: |-
|
||||||
{
|
{
|
||||||
"name": "Carol 3",
|
"name": "Bob",
|
||||||
"email": "carol5@example.com",
|
"email": "bob@example.com",
|
||||||
"password": "pa55word"
|
"password": "pa55word"
|
||||||
}
|
}
|
||||||
auth: inherit
|
auth: inherit
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ var (
|
|||||||
// like a UserModel and PermissionModel, as our build progresses.
|
// like a UserModel and PermissionModel, as our build progresses.
|
||||||
type Models struct {
|
type Models struct {
|
||||||
Movies MovieModel
|
Movies MovieModel
|
||||||
|
Tokens TokenModel // Add a new Tokens field.
|
||||||
Users UserModel
|
Users UserModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type Models struct {
|
|||||||
func NewModels(db *sql.DB) Models {
|
func NewModels(db *sql.DB) Models {
|
||||||
return Models{
|
return Models{
|
||||||
Movies: MovieModel{DB: db},
|
Movies: MovieModel{DB: db},
|
||||||
|
Tokens: TokenModel{DB: db}, // Initialize a new TokenModel instance.
|
||||||
Users: UserModel{DB: db},
|
Users: UserModel{DB: db},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base32"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"greenlight.debuggingjon.dev/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Define constants for the token scope. For now we just define the scope "activation"
|
||||||
|
// but we'll add additional scopes later in the book.
|
||||||
|
const (
|
||||||
|
ScopeActivation = "activation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Define a Token struct to hold the data for an individual token. This includes the
|
||||||
|
// plaintext and hashed versions of the token, associated user ID, expiry time and
|
||||||
|
// scope.
|
||||||
|
type Token struct {
|
||||||
|
Plaintext string
|
||||||
|
Hash []byte
|
||||||
|
UserID int64
|
||||||
|
Expiry time.Time
|
||||||
|
Scope string
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) {
|
||||||
|
// Create a Token instance containing the user ID, expiry, and scope information.
|
||||||
|
// Notice that we add the provided ttl (time-to-live) duration parameter to the
|
||||||
|
// current time to get the expiry time?
|
||||||
|
token := &Token{
|
||||||
|
UserID: userID,
|
||||||
|
Expiry: time.Now().Add(ttl),
|
||||||
|
Scope: scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a zero-valued byte slice with a length of 16 bytes.
|
||||||
|
randomBytes := make([]byte, 16)
|
||||||
|
|
||||||
|
// Use the Read() function from the crypto/rand package to fill the byte slice with
|
||||||
|
// random bytes from your operating system's CSPRNG. This will return an error if
|
||||||
|
// the CSPRNG fails to function correctly.
|
||||||
|
_, err := rand.Read(randomBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the byte slice to a base-32-encoded string and assign it to the token
|
||||||
|
// Plaintext field. This will be the token string that we send to the user in their
|
||||||
|
// welcome email. They will look similar to this:
|
||||||
|
//
|
||||||
|
// Y3QMGX3PJ3WLRL2YRTQGQ6KRHU
|
||||||
|
//
|
||||||
|
// Note that by default base-32 strings may be padded at the end with the =
|
||||||
|
// character. We don't need this padding character for the purpose of our tokens, so
|
||||||
|
// we use the WithPadding(base32.NoPadding) method in the line below to omit them.
|
||||||
|
token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
|
||||||
|
|
||||||
|
// Generate a SHA-256 hash of the plaintext token string. This will be the value
|
||||||
|
// that we store in the `hash` field of our database table. Note that the
|
||||||
|
// sha256.Sum256() function returns an *array* of length 32, so to make it easier to
|
||||||
|
// work with we convert it to a slice using the [:] operator before storing it.
|
||||||
|
hash := sha256.Sum256([]byte(token.Plaintext))
|
||||||
|
token.Hash = hash[:]
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the plaintext token has been provided and is exactly 52 bytes long.
|
||||||
|
func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) {
|
||||||
|
v.Check(tokenPlaintext != "", "token", "must be provided")
|
||||||
|
v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the TokenModel type.
|
||||||
|
type TokenModel struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// The New() method is a shortcut which creates a new Token struct and then inserts the
|
||||||
|
// data in the tokens table.
|
||||||
|
func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) {
|
||||||
|
token, err := generateToken(userID, ttl, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.Insert(token)
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert() adds the data for a specific token to the tokens table.
|
||||||
|
func (m TokenModel) Insert(token *Token) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO tokens (hash, user_id, expiry, scope)
|
||||||
|
VALUES ($1, $2, $3, $4)`
|
||||||
|
|
||||||
|
args := []interface{}{token.Hash, token.UserID, token.Expiry, token.Scope}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := m.DB.ExecContext(ctx, query, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllForUser() deletes all tokens for a specific user and scope.
|
||||||
|
func (m TokenModel) DeleteAllForUser(scope string, userID int64) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM tokens
|
||||||
|
WHERE scope = $1 AND user_id = $2`
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := m.DB.ExecContext(ctx, query, scope, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package data
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
@@ -213,3 +214,52 @@ func (m UserModel) Update(user *User) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) {
|
||||||
|
// Calculate the SHA-256 hash of the plaintext token provided by the client.
|
||||||
|
// Remember that this returns a byte *array* with length 32, not a slice.
|
||||||
|
tokenHash := sha256.Sum256([]byte(tokenPlaintext))
|
||||||
|
// Set up the SQL query.
|
||||||
|
query := `
|
||||||
|
SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
|
||||||
|
FROM users
|
||||||
|
INNER JOIN tokens
|
||||||
|
ON users.id = tokens.user_id
|
||||||
|
WHERE tokens.hash = $1
|
||||||
|
AND tokens.scope = $2
|
||||||
|
AND tokens.expiry > $3`
|
||||||
|
|
||||||
|
// Create a slice containing the query arguments. Notice how we use the [:] operator
|
||||||
|
// to get a slice containing the token hash, rather than passing in the array (which
|
||||||
|
// is not supported by the pq driver), and that we pass the current time as the
|
||||||
|
// value to check against the token expiry.
|
||||||
|
args := []interface{}{tokenHash[:], tokenScope, time.Now()}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Execute the query, scanning the return values into a User struct. If no matching
|
||||||
|
// record is found we return an ErrRecordNotFound error.
|
||||||
|
err := m.DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.Name,
|
||||||
|
&user.Email,
|
||||||
|
&user.Password.hash,
|
||||||
|
&user.Activated,
|
||||||
|
&user.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrRecordNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the matching user.
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ Hi,
|
|||||||
|
|
||||||
Thanks for signing up for a Greenlight account. We're excited to have you on board!
|
Thanks for signing up for a Greenlight account. We're excited to have you on board!
|
||||||
|
|
||||||
For future reference, your user ID number is {{.ID}}.
|
For future reference, your user ID number is {{.userID}}.
|
||||||
|
|
||||||
|
Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON
|
||||||
|
body to activate your account:
|
||||||
|
|
||||||
|
{"token": "{{.activationToken}}"}
|
||||||
|
|
||||||
|
Please note that this is a one-time use token and it will expire in 3 days.
|
||||||
|
|
||||||
Thanks,
|
Thanks,
|
||||||
|
|
||||||
@@ -24,10 +31,17 @@ The Greenlight Team
|
|||||||
<body>
|
<body>
|
||||||
<p>Hi,</p>
|
<p>Hi,</p>
|
||||||
<p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
|
<p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
|
||||||
<p>For future reference, your user ID number is {{.ID}}.</p>
|
<p>For future reference, your user ID number is {{.userID}}.</p>
|
||||||
|
<p>Please send a request to the <code>PUT /v1/users/activated</code> endpoint with the
|
||||||
|
following JSON body to activate your account:</p>
|
||||||
|
<pre><code>
|
||||||
|
{"token": "{{.activationToken}}"}
|
||||||
|
</code></pre>
|
||||||
|
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
|
||||||
<p>Thanks,</p>
|
<p>Thanks,</p>
|
||||||
<p>The Greenlight Team</p>
|
<p>The Greenlight Team</p>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user