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.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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
data: |-
|
||||
{
|
||||
"name": "Carol 3",
|
||||
"email": "carol5@example.com",
|
||||
"name": "Bob",
|
||||
"email": "bob@example.com",
|
||||
"password": "pa55word"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<body>
|
||||
<p>Hi,</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>The Greenlight Team</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user