diff --git a/projects/greenlight/cmd/api/context.go b/projects/greenlight/cmd/api/context.go new file mode 100644 index 0000000..aa1ffc2 --- /dev/null +++ b/projects/greenlight/cmd/api/context.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "net/http" + + "greenlight.debuggingjon.dev/internal/data" +) + +// Define a custom contextKey type, with the underlying type string. +type contextKey string + +// Convert the string "user" to a contextKey type and assign it to the userContextKey +// constant. We'll use this constant as the key for getting and setting user information +// in the request context. +const userContextKey = contextKey("user") + +// The contextSetUser() method returns a new copy of the request with the provided +// User struct added to the context. Note that we use our userContextKey constant as the +// key. +func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request { + ctx := context.WithValue(r.Context(), userContextKey, user) + return r.WithContext(ctx) +} + +// The contextSetUser() retrieves the User struct from the request context. The only +// time that we'll use this helper is when we logically expect there to be User struct +// value in the context, and if it doesn't exist it will firmly be an 'unexpected' error. +// As we discussed earlier in the book, it's OK to panic in those circumstances. +func (app *application) contextGetUser(r *http.Request) *data.User { + user, ok := r.Context().Value(userContextKey).(*data.User) + if !ok { + panic("missing user value in request context") + } + + return user +} diff --git a/projects/greenlight/cmd/api/errors.go b/projects/greenlight/cmd/api/errors.go index b49a42b..1f694ee 100644 --- a/projects/greenlight/cmd/api/errors.go +++ b/projects/greenlight/cmd/api/errors.go @@ -76,3 +76,15 @@ func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http message := "rate limit exceeded" app.errorResponse(w, r, http.StatusTooManyRequests, message) } + +func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { + message := "invalid authentication credentials" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "Bearer") + + message := "invalid or missing authentication token" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} diff --git a/projects/greenlight/cmd/api/middleware.go b/projects/greenlight/cmd/api/middleware.go index ef30092..3e159d1 100644 --- a/projects/greenlight/cmd/api/middleware.go +++ b/projects/greenlight/cmd/api/middleware.go @@ -1,13 +1,17 @@ package main import ( + "errors" "fmt" "net" "net/http" + "strings" "sync" "time" "golang.org/x/time/rate" + "greenlight.debuggingjon.dev/internal/data" + "greenlight.debuggingjon.dev/internal/validator" ) func (app *application) recoverPanic(next http.Handler) http.Handler { @@ -107,3 +111,73 @@ func (app *application) rateLimit(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func (app *application) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add the "Vary: Authorization" header to the response. This indicates to any + // caches that the response may vary based on the value of the Authorization + // header in the request. + w.Header().Add("Vary", "Authorization") + + // Retrieve the value of the Authorization header from the request. This will + // return the empty string "" if there is no such header found. + authorizationHeader := r.Header.Get("Authorization") + + // If there is no Authorization header found, use the contextSetUser() helper + // that we just made to add the AnonymousUser to the request context. Then we + // call the next handler in the chain and return without executing any of the + // code below. + if authorizationHeader == "" { + r = app.contextSetUser(r, data.AnonymousUser) + next.ServeHTTP(w, r) + return + } + + // Otherwise, we expect the value of the Authorization header to be in the format + // "Bearer ". We try to split this into its constituent parts, and if the + // header isn't in the expected format we return a 401 Unauthorized response + // using the invalidAuthenticationTokenResponse() helper (which we will create + // in a moment). + headerParts := strings.Split(authorizationHeader, " ") + if len(headerParts) != 2 || headerParts[0] != "Bearer" { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + // Extract the actual authentication token from the header parts. + token := headerParts[1] + + // Validate the token to make sure it is in a sensible format. + v := validator.New() + + // If the token isn't valid, use the invalidAuthenticationTokenResponse() + // helper to send a response, rather than the failedValidationResponse() helper + // that we'd normally use. + if data.ValidateTokenPlaintext(v, token); !v.Valid() { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + // Retrieve the details of the user associated with the authentication token, + // again calling the invalidAuthenticationTokenResponse() helper if no + // matching record was found. IMPORTANT: Notice that we are using + // ScopeAuthentication as the first parameter here. + user, err := app.models.Users.GetForToken(data.ScopeAuthentication, token) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + app.invalidAuthenticationTokenResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + // Call the contextSetUser() helper to add the user information to the request + // context. + r = app.contextSetUser(r, user) + + // Call the next handler in the chain. + next.ServeHTTP(w, r) + }) +} diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index 7cc7f69..ce87a1f 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -25,6 +25,8 @@ func (app *application) routes() http.Handler { 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) + router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) // Wrap the router with the rateLimit() middleware. - return app.recoverPanic(app.rateLimit(router)) + // Use the authenticate() middleware on all requests. + return app.recoverPanic(app.rateLimit(app.authenticate(router))) } diff --git a/projects/greenlight/cmd/api/tokens.go b/projects/greenlight/cmd/api/tokens.go new file mode 100644 index 0000000..ea4664e --- /dev/null +++ b/projects/greenlight/cmd/api/tokens.go @@ -0,0 +1,77 @@ +package main + +import ( + "errors" + "net/http" + "time" + + "greenlight.debuggingjon.dev/internal/data" + "greenlight.debuggingjon.dev/internal/validator" +) + +func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { + // Parse the email and password from the request body. + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + // Validate the email and password provided by the client. + v := validator.New() + + data.ValidateEmail(v, input.Email) + data.ValidatePasswordPlaintext(v, input.Password) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Lookup the user record based on the email address. If no matching user was + // found, then we call the app.invalidCredentialsResponse() helper to send a 401 + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + app.invalidCredentialsResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + // Check if the provided password matches the actual password for the user. + match, err := user.Password.Matches(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // If the passwords don't match, then we call the app.invalidCredentialsResponse() + // helper again and return. + if !match { + app.invalidCredentialsResponse(w, r) + return + } + + // Otherwise, if the password is correct, we generate a new token with a 24-hour + // expiry time and the scope 'authentication'. + token, err := app.models.Tokens.New(user.ID, 24*time.Hour, data.ScopeAuthentication) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Encode the token to JSON and send it in the response along with a 201 Created + // status code. + err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml b/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml new file mode 100644 index 0000000..959fb6b --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml @@ -0,0 +1,22 @@ +info: + name: Authenticate + type: http + seq: 1 + +http: + method: POST + url: "{{URL}}/v1/tokens/authentication" + body: + type: json + data: |- + { + "email": "bob@example.com", + "password": "pa55word" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/docs/api/greenlight-bruno/Authentication/folder.yml b/projects/greenlight/docs/api/greenlight-bruno/Authentication/folder.yml new file mode 100644 index 0000000..b31d521 --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Authentication/folder.yml @@ -0,0 +1,7 @@ +info: + name: Authentication + type: folder + seq: 4 + +request: + auth: inherit diff --git a/projects/greenlight/docs/api/greenlight-bruno/Healthcheck.yml b/projects/greenlight/docs/api/greenlight-bruno/Healthcheck.yml index 3ad92d3..b7c222e 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Healthcheck.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Healthcheck.yml @@ -6,6 +6,9 @@ info: http: method: GET url: "{{URL}}{{VERSION}}/healthcheck" + headers: + - name: Authorization + value: Bearer 5AGLBDVJA6X2VTBLYCQ3KBROHM auth: inherit settings: diff --git a/projects/greenlight/internal/data/tokens.go b/projects/greenlight/internal/data/tokens.go index 4d6a685..bc19a7a 100644 --- a/projects/greenlight/internal/data/tokens.go +++ b/projects/greenlight/internal/data/tokens.go @@ -14,18 +14,20 @@ import ( // 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" + ScopeActivation = "activation" + ScopeAuthentication = "authentication" // Include a new authentication scope. + ) // 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 + Plaintext string `json:"token"` + Hash []byte `json:"-"` + UserID int64 `json:"-"` + Expiry time.Time `json:"expiry"` + Scope string `json:"-"` } func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) { diff --git a/projects/greenlight/internal/data/users.go b/projects/greenlight/internal/data/users.go index 3dbe9dd..d8fc7ad 100644 --- a/projects/greenlight/internal/data/users.go +++ b/projects/greenlight/internal/data/users.go @@ -39,6 +39,14 @@ type password struct { hash []byte } +// Declare a new AnonymousUser variable. +var AnonymousUser = &User{} + +// Check if a User instance is the AnonymousUser. +func (u *User) IsAnonymous() bool { + return u == AnonymousUser +} + // The Set() method calculates the bcrypt hash of a plaintext password, and stores both // the hash and the plaintext versions in the struct. func (p *password) Set(plaintextPassword string) error {