diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index 9069e99..7cc7f69 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -24,7 +24,7 @@ func (app *application) routes() http.Handler { router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler) router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) - + router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) // Wrap the router with the rateLimit() middleware. return app.recoverPanic(app.rateLimit(router)) } diff --git a/projects/greenlight/cmd/api/users.go b/projects/greenlight/cmd/api/users.go index 1ec3151..b6fa920 100644 --- a/projects/greenlight/cmd/api/users.go +++ b/projects/greenlight/cmd/api/users.go @@ -3,6 +3,7 @@ package main import ( "errors" "net/http" + "time" "greenlight.debuggingjon.dev/internal/data" "greenlight.debuggingjon.dev/internal/validator" @@ -50,13 +51,9 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque return } - // Insert the user data into the database. err = app.models.Users.Insert(user) if err != nil { 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): v.AddError("email", "a user with this email address already exists") app.failedValidationResponse(w, r, v.Errors) @@ -66,20 +63,99 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque return } - // Use the background helper to execute an anonymous function that sends the welcome - // email. + // After the user record has been created in the database, generate a new activation + // 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() { - 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 { 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) if err != nil { 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) + } +} diff --git a/projects/greenlight/docs/api/greenlight-bruno/Users/Activate User.yml b/projects/greenlight/docs/api/greenlight-bruno/Users/Activate User.yml new file mode 100644 index 0000000..12fb3f4 --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Users/Activate User.yml @@ -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 diff --git a/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml b/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml index 26198a5..ec6f1c9 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml @@ -10,8 +10,8 @@ http: type: json data: |- { - "name": "Carol 3", - "email": "carol5@example.com", + "name": "Bob", + "email": "bob@example.com", "password": "pa55word" } auth: inherit diff --git a/projects/greenlight/internal/data/models.go b/projects/greenlight/internal/data/models.go index ba1a718..3d07f5c 100644 --- a/projects/greenlight/internal/data/models.go +++ b/projects/greenlight/internal/data/models.go @@ -16,6 +16,7 @@ var ( // like a UserModel and PermissionModel, as our build progresses. type Models struct { Movies MovieModel + Tokens TokenModel // Add a new Tokens field. Users UserModel } @@ -25,6 +26,7 @@ type Models struct { func NewModels(db *sql.DB) Models { return Models{ Movies: MovieModel{DB: db}, + Tokens: TokenModel{DB: db}, // Initialize a new TokenModel instance. Users: UserModel{DB: db}, } } diff --git a/projects/greenlight/internal/data/tokens.go b/projects/greenlight/internal/data/tokens.go new file mode 100644 index 0000000..4d6a685 --- /dev/null +++ b/projects/greenlight/internal/data/tokens.go @@ -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 +} diff --git a/projects/greenlight/internal/data/users.go b/projects/greenlight/internal/data/users.go index 57ac2ec..3dbe9dd 100644 --- a/projects/greenlight/internal/data/users.go +++ b/projects/greenlight/internal/data/users.go @@ -2,6 +2,7 @@ package data import ( "context" + "crypto/sha256" "database/sql" "errors" "time" @@ -213,3 +214,52 @@ func (m UserModel) Update(user *User) error { 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 +} diff --git a/projects/greenlight/internal/mailer/templates/user_welcome.tmpl b/projects/greenlight/internal/mailer/templates/user_welcome.tmpl index c422a0d..2c64831 100644 --- a/projects/greenlight/internal/mailer/templates/user_welcome.tmpl +++ b/projects/greenlight/internal/mailer/templates/user_welcome.tmpl @@ -5,7 +5,14 @@ Hi, 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, @@ -24,10 +31,17 @@ The Greenlight Team

Hi,

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,

The Greenlight Team

{{end}} +