feat: permissions, middleware authentication, migrations

This commit is contained in:
2026-04-23 13:47:58 +02:00
parent 1017e0cb82
commit d629bd52eb
9 changed files with 190 additions and 27 deletions
+15
View File
@@ -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)
}
+63
View File
@@ -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)
}
+14 -14
View File
@@ -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)))
}
@@ -10,7 +10,7 @@ http:
type: json
data: |-
{
"email": "bob@example.com",
"email": "alice@example.com",
"password": "pa55word"
}
auth: inherit
@@ -24,7 +24,9 @@ http:
value: action
type: query
disabled: true
auth: inherit
auth:
type: bearer
token: VSLRNLGSTN2BXGE7PCEPB4LNKQ
settings:
encodeUrl: true
+8 -11
View File
@@ -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},
}
}
@@ -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
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS users_permissions;
DROP TABLE IF EXISTS permissions;
@@ -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');