diff --git a/projects/greenlight/cmd/api/errors.go b/projects/greenlight/cmd/api/errors.go index 1f694ee..91bf016 100644 --- a/projects/greenlight/cmd/api/errors.go +++ b/projects/greenlight/cmd/api/errors.go @@ -88,3 +88,18 @@ func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter message := "invalid or missing authentication token" app.errorResponse(w, r, http.StatusUnauthorized, message) } + +func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { + message := "you must be authenticated to access this resource" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) { + message := "your user account must be activated to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) { + message := "your user account doesn't have the necessary permissions to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} diff --git a/projects/greenlight/cmd/api/middleware.go b/projects/greenlight/cmd/api/middleware.go index 3e159d1..a7cf295 100644 --- a/projects/greenlight/cmd/api/middleware.go +++ b/projects/greenlight/cmd/api/middleware.go @@ -181,3 +181,66 @@ func (app *application) authenticate(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// anonymous. +func (app *application) requireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + if user.IsAnonymous() { + app.authenticationRequiredResponse(w, r) + return + } + + next.ServeHTTP(w, r) + }) +} + +// Checks that a user is both authenticated and activated. +func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc { + // Rather than returning this http.HandlerFunc we assign it to the variable fn. + fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + // Check that a user is activated. + if !user.Activated { + app.inactiveAccountResponse(w, r) + return + } + + next.ServeHTTP(w, r) + }) + + // Wrap fn with the requireAuthenticatedUser() middleware before returning it. + return app.requireAuthenticatedUser(fn) +} + +// Note that the first parameter for the middleware function is the permission code that +// we require the user to have. +func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc { + fn := func(w http.ResponseWriter, r *http.Request) { + // Retrieve the user from the request context. + user := app.contextGetUser(r) + + // Get the slice of permissions for the user. + permissions, err := app.models.Permissions.GetAllForUser(user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Check if the slice includes the required permission. If it doesn't, then + // return a 403 Forbidden response. + if !permissions.Include(code) { + app.notPermittedResponse(w, r) + return + } + + // Otherwise they have the required permission so we call the next handler in + // the chain. + next.ServeHTTP(w, r) + } + + // Wrap this with the requireActivatedUser() middleware before returning it. + return app.requireActivatedUser(fn) +} diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index ce87a1f..2959028 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -9,24 +9,24 @@ import ( func (app *application) routes() http.Handler { router := httprouter.New() - // Convert the notFoundResponse() helper to a http.Handler using the - // http.HandlerFunc() adapter, and then set it as the custom error handler for 404 - // Not Found responses. router.NotFound = http.HandlerFunc(app.notFoundResponse) - - // Likewise, convert the methodNotAllowedResponse() helper to a http.Handler and set - // it as the custom error handler for 405 Method Not Allowed responses. router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) - router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler) + router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) - router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) - router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) - router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler) - router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler) + + // Use the requirePermission() middleware on each of the /v1/movies** endpoints, + // passing in the required permission code as the first parameter. + router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler)) + router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler)) + router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", 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. - // Use the authenticate() middleware on all requests. + + router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", + app.createAuthenticationTokenHandler) + return app.recoverPanic(app.rateLimit(app.authenticate(router))) } diff --git a/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml b/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml index 959fb6b..777af9e 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml @@ -10,7 +10,7 @@ http: type: json data: |- { - "email": "bob@example.com", + "email": "alice@example.com", "password": "pa55word" } auth: inherit diff --git a/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml index 0ac0b1d..82b0535 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml @@ -24,7 +24,9 @@ http: value: action type: query disabled: true - auth: inherit + auth: + type: bearer + token: VSLRNLGSTN2BXGE7PCEPB4LNKQ settings: encodeUrl: true diff --git a/projects/greenlight/internal/data/models.go b/projects/greenlight/internal/data/models.go index 743cec5..39fe2cc 100644 --- a/projects/greenlight/internal/data/models.go +++ b/projects/greenlight/internal/data/models.go @@ -12,21 +12,18 @@ var ( ErrEditConflict = errors.New("edit conflict") ) -// Models struct which wraps the MovieModel. We'll add other models to this, -// like a UserModel and PermissionModel, as our build progresses. type Models struct { - Movies MovieModel - Tokens TokenModel // Add a new Tokens field. - Users UserModel + Movies MovieModel + Permissions PermissionModel // Add a new Permissions field. + Tokens TokenModel + Users UserModel } -// NewModels For ease of use, we also add a New() -// method which returns a Models struct containing -// the initialized MovieModel. 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}, + Movies: MovieModel{DB: db}, + Permissions: PermissionModel{DB: db}, // Initialize a new PermissionModel instance. + Tokens: TokenModel{DB: db}, + Users: UserModel{DB: db}, } } diff --git a/projects/greenlight/internal/data/permissions.go b/projects/greenlight/internal/data/permissions.go new file mode 100644 index 0000000..29df375 --- /dev/null +++ b/projects/greenlight/internal/data/permissions.go @@ -0,0 +1,66 @@ +package data + +import ( + "context" + "database/sql" + "time" +) + +// Define a Permissions slice, which we will use to will hold the permission codes (like +// "movies:read" and "movies:write") for a single user. +type Permissions []string + +// Add a helper method to check whether the Permissions slice contains a specific +// permission code. +func (p Permissions) Include(code string) bool { + for i := range p { + if code == p[i] { + return true + } + } + return false +} + +// PermissionModel type. +type PermissionModel struct { + DB *sql.DB +} + +// GetAllForUser method returns all permission codes for a specific user in a +// Permissions slice. The code in this method should feel very familiar --- it uses the +// standard pattern that we've already seen before for retrieving multiple data rows in +// an SQL query. +func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) { + query := ` + SELECT permissions.code + FROM permissions + INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id + INNER JOIN users ON users_permissions.user_id = users.id + WHERE users.id = $1` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + rows, err := m.DB.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var permissions Permissions + + for rows.Next() { + var permission string + + err := rows.Scan(&permission) + if err != nil { + return nil, err + } + + permissions = append(permissions, permission) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return permissions, nil +} diff --git a/projects/greenlight/migrations/000006_add_permissions.down.sql b/projects/greenlight/migrations/000006_add_permissions.down.sql new file mode 100644 index 0000000..18d324d --- /dev/null +++ b/projects/greenlight/migrations/000006_add_permissions.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS users_permissions; +DROP TABLE IF EXISTS permissions; + diff --git a/projects/greenlight/migrations/000006_add_permissions.up.sql b/projects/greenlight/migrations/000006_add_permissions.up.sql new file mode 100644 index 0000000..5017b0a --- /dev/null +++ b/projects/greenlight/migrations/000006_add_permissions.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS permissions ( + id bigserial PRIMARY KEY, + code text NOT NULL +); + +CREATE TABLE IF NOT EXISTS users_permissions ( + user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, + permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE, + PRIMARY KEY (user_id, permission_id) +); + +-- Add the two permissions to the table. +INSERT INTO permissions (code) +VALUES + ('movies:read'), + ('movies:write'); +