In errors.go:
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) }
In middleware.go:
func (app *application) requireActivatedUser(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 } if !user.Activated { app.inactiveAccountResponse(w, r) return } next.ServeHTTP(w, r) }) }
In routes.go:
// Use the requireActivatedUser() middleware on /v1/movies** endpoints. router.HandlerFunc(http.MethodGet, "/v1/movies", app.requireActivatedUser(app.listMoviesHandler)) router.HandlerFunc(http.MethodPost, "/v1/movies", app.requireActivatedUser(app.createMovieHandler)) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requireActivatedUser(app.showMovieHandler)) router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requireActivatedUser(app.updateMovieHandler)) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requireActivatedUser(app.deleteMovieHandler))
zzh@ZZHPC:~$ curl -i localhost:4000/v1/movies/1 HTTP/1.1 401 Unauthorized Content-Type: application/json Vary: Authorization Date: Fri, 22 Nov 2024 07:55:28 GMT Content-Length: 69 { "error": "you must be authenticated to access this resource" }
zzh@ZZHPC:~$ curl -i -d '{"email": "alice@example.com", "password": "pa55word"}' localhost:4000/v1/tokens/authentication HTTP/1.1 201 Created Content-Type: application/json Vary: Authorization Date: Fri, 22 Nov 2024 08:04:48 GMT Content-Length: 143 { "authentication_token": { "token": "NCSFGSV7Q62PZ3FVP4MDKBOQCQ", "expiry": "2024-11-23T16:04:48.300138435+08:00" } } zzh@ZZHPC:~$ curl -i -H "Authorization: Bearer NCSFGSV7Q62PZ3FVP4MDKBOQCQ" localhost:4000/v1/movies/1 HTTP/1.1 403 Forbidden Content-Type: application/json Vary: Authorization Date: Fri, 22 Nov 2024 08:05:28 GMT Content-Length: 79 { "error": "your user account must be activated to access this resource" }
zzh@ZZHPC:~$ BODY='{"email": "ZhangZhihuiAAA@126.com", "password": "pa55word"}' zzh@ZZHPC:~$ curl -i -H "Authorization: Bearer RULKBNEGFJBHRE3ADD7HUGVHSQ" localhost:4000/v1/movies/1 HTTP/1.1 200 OK Content-Type: application/json Vary: Authorization Date: Fri, 22 Nov 2024 07:59:07 GMT Content-Length: 222 { "movie": { "id": 1, "title": "Moana", "year": 2016, "runtime": "107 mins", "genres": [ "animation", "adventure" ], "version": 1 } }
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) }) } 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) if !user.Activated { app.inactiveAccountResponse(w, r) return } next.ServeHTTP(w, r) }) return app.requireAuthenticatedUser(fn) }
zzh@ZZHPC:/zdata/Github/greenlight$ migrate create -seq -ext .sql -dir ./migrations add_permissions /zdata/Github/greenlight/migrations/000006_add_permissions.up.sql /zdata/Github/greenlight/migrations/000006_add_permissions.down.sql
CREATE TABLE IF NOT EXISTS permission ( id bigserial PRIMARY KEY, code text NOT NULL ); CREATE TABLE IF NOT EXISTS user_permission ( user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, permission_id bigint NOT NULL REFERENCES permission ON DELETE CASCADE, PRIMARY KEY (user_id, permission_id) ); INSERT INTO permission (code) VALUES ('movie:read'), ('movie:write');
DROP TABLE IF EXISTS user_permission; DROP TABLE IF EXISTS permission;
zzh@ZZHPC:/zdata/Github/greenlight$ make migrate_up 6/u add_permissions (16.965477ms)
package data import ( "context" "slices" "time" ) // Permissions stores the permission codes for a single user. type Permissions []string // Include checks whether the Permissions slice contains a specific permission code. func (p Permissions) Include(code string) bool { return slices.Contains(p, code) } // PermissionModel struct wraps a database connection pool wrapper. type PermissionModel struct { DB *PoolWrapper } // GetAllForUser returns all permission codes for a specific user. func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) { query := `SELECT p.code FROM permissions p INNER JOIN user_permission up ON up.permission_id = p.id INNER JOIN users u ON up.user_id = u.id WHERE u.id = $1` ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() rows, err := m.DB.Pool.Query(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 }
func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc { fn := func(w http.ResponseWriter, r *http.Request) { user := app.contextGetUser(r) permissions, err := app.models.Permission.GetAllForUser(user.ID) if err != nil { app.serverErrorResponse(w, r, err) return } if !permissions.Include(code) { app.notPermittedResponse(w, r) return } next.ServeHTTP(w, r) } return app.requireActivatedUser(fn) }
// Use the requirePermission() middleware on /v1/movies** endpoints. router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movie:read", app.listMoviesHandler)) router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movie:write", app.createMovieHandler)) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movie:read", app.showMovieHandler)) router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movie:write", app.updateMovieHandler)) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movie:write", app.deleteMovieHandler))
Insert some records into the user_permission table:
greenlight=> INSERT INTO user_permission greenlight-> SELECT id, (SELECT id FROM permission WHERE code = 'movie:read') FROM users; INSERT 0 2 greenlight=> INSERT INTO user_permission VALUES (9, 2); INSERT 0 1 greenlight=> SELECT * FROM user_permission; user_id | permission_id ---------+--------------- 1 | 1 9 | 1 9 | 2 (3 rows)
Activated the user whose id is 1:
greenlight=> UPDATE users SET activated = true WHERE id = 1; UPDATE 1
zzh@ZZHPC:~$ BODY='{"email": "alice@example.com", "password": "pa55word"}' zzh@ZZHPC:~$ curl -d "$BODY" localhost:4000/v1/tokens/authentication { "authentication_token": { "token": "JYL3P33DFLDVICZHLI46EEM73E", "expiry": "2024-11-23T17:29:37.512601321+08:00" } } zzh@ZZHPC:~$ curl -H "Authorization: Bearer JYL3P33DFLDVICZHLI46EEM73E" localhost:4000/v1/movies/1 { "movie": { "id": 1, "title": "Moana", "year": 2016, "runtime": "107 mins", "genres": [ "animation", "adventure" ], "version": 1 } } zzh@ZZHPC:~$ curl -X DELETE -H "Authorization: Bearer JYL3P33DFLDVICZHLI46EEM73E" localhost:4000/v1/movies/1 { "error": "your user account doesn't have the necessary permissions to access this resource" }
zzh@ZZHPC:~$ BODY='{"email": "ZhangZhihuiAAA@126.com", "password": "pa55word"}' zzh@ZZHPC:~$ curl -d "$BODY" localhost:4000/v1/tokens/authentication { "authentication_token": { "token": "RZKLILBMKFV3QJEEREALBHDBYE", "expiry": "2024-11-23T17:37:57.567581398+08:00" } } zzh@ZZHPC:~$ curl -X DELETE -H "Authorization: Bearer RZKLILBMKFV3QJEEREALBHDBYE" localhost:4000/v1/movies/1 { "message": "movie successfully deleted" }
In permission.go:
// AddForUser adds the provided permissions for a specific user. func (m PermissionModel) AddForUser(userID int64, codes ...string) error { query := `INSERT INTO user_permission SELECT $1, id FROM permission WHERE code = ANY($2)` ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _, err := m.DB.Pool.Exec(ctx, query, userID, codes) return err }
func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } user := &data.User{ Name: input.Name, Email: input.Email, Activated: false, } err = user.Password.Set(input.Password) if err != nil { app.serverErrorResponse(w, r, err) return } v := validator.New() if data.ValidateUser(v, user); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } // Insert the user data into the database. err = app.models.User.Insert(user) if err != nil { switch { case errors.Is(err, data.ErrDuplicateEmail): v.AddError("email", "a user with this email address already exists") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // Add the "movie:read" permission for the new user. err = app.models.Permission.AddForUser(user.ID, "movie:read") if err != nil { app.serverErrorResponse(w, r, err) return } // After the user record is created in the database, generate a new activation token // for the user. token, err := app.models.Token.New(user.ID, 3*24*time.Hour, data.ScopeActivation) if err != nil { app.serverErrorResponse(w, r, err) return } // Send the welcome email in background. app.background(func() { data := map[string]any{ "activationToken": token.Plaintext, "userID": user.ID, } err = app.emailSender.Send(user.Email, "user_welcome.html", data) if err != nil { app.logger.Error(err.Error()) } }) err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
Let’s check that this is working correctly by registering a brand-new user.
greenlight=> DELETE FROM users where id = 9; DELETE 1 greenlight=> SELECT id, name, email FROM users; id | name | email ----+-------------+------------------- 1 | Alice Smith | alice@example.com (1 row) greenlight=> SELECT * FROM user_permission; user_id | permission_id ---------+--------------- 1 | 1 (1 row)
greenlight=> SELECT u.email, p.code greenlight-> FROM users u greenlight-> INNER JOIN user_permission up ON u.id = up.user_id greenlight-> INNER JOIN permission p ON up.permission_id = p.id greenlight-> WHERE u.email = 'ZhangZhihuiAAA@126.com'; email | code ------------------------+------------ ZhangZhihuiAAA@126.com | movie:read (1 row)