16. Permisos basados en autorización

Cuando una solicitud sale de nuestro middleware authenticate(), ahora hay dos posibles estados para el contexto de la solicitud. Ya sea:

  • El contexto de la solicitud contiene una estructura de usuario (User struct) que representa a un usuario válido y autenticado.
  • O el contexto de la solicitud contiene una estructura de Usuario Anónimo (AnonymousUser struct).

Por supuesto, aquí tienes tu texto traducido y separado en párrafos:

Vamos a llevar esto al siguiente paso natural y ver cómo realizar diferentes verificaciones de autorización para restringir el acceso a los puntos finales de nuestra API.

Específicamente, aprenderás cómo:

  • Agregar comprobaciones para que solo los usuarios activados puedan acceder a los diversos puntos finales /v1/movies**.

  • Implementar un patrón de autorización basado en permisos, que brinda un control detallado sobre exactamente qué usuarios pueden acceder a qué puntos finales.

16.1 Exigir Activación de usuario

Como mencionamos hace un momento, lo primero que vamos a hacer en términos de autorización es restringir el acceso a nuestros puntos finales /v1/movies**+ para que solo puedan ser accedidos por usuarios autenticados (no anónimos) y que hayan activado su cuenta.

Realizar este tipo de verificaciones es una tarea ideal para un middleware, así que vamos a crear un nuevo método middleware llamado requireActivatedUser() para manejar esto. En este middleware, queremos extraer la estructura de usuario (User struct) del contexto de la solicitud y luego verificar el método IsAnonymous() y el campo Activated para determinar si la solicitud debe continuar o no.

Si el usuario es anónimo, debemos enviar una respuesta 401 No autorizado y un mensaje de error que diga “Debes estar autenticado para acceder a este recurso”.

Si el usuario no es anónimo (es decir, se ha autenticado correctamente y sabemos quién es) , pero no está activado, deberíamos enviar una respuesta 403 Prohibido y un mensaje de error que diga “Tu cuenta de usuario debe estar activada para acceder a este recurso”.

Recuerda: Una respuesta 401 No autorizado debe usarse cuando hay autenticación faltante o incorrecta, y una respuesta 403 Prohibido debe usarse después, cuando el usuario está autenticado pero no se le permite realizar la operación solicitada.

procede a tu archivo cmd/api/errors.go y agrega un par de nuevos helpers para enviar esos mensajes de error.

package main

import (
    "net/http"
)

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)
}

Y luego creemos el nuevo middleware requireActivatedUser() para llevar a cabo las verificaciones. El código que necesitamos es conciso y claro.

package main

func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Utiliza el ayudante contextGetUser() que creamos anteriormente para recuperar la información del usuario del contexto de la solicitud.
        user := app.contextGetUser(r)

        // Si el usuario es anónimo, llama a authenticationRequiredResponse() para informar al cliente que debe autenticarse antes de intentarlo de nuevo.
        if user.IsAnonymous() {
            app.authenticationRequiredResponse(w, r)
            return
        }

        // Si el usuario no está activado, utiliza el ayudante inactiveAccountResponse() para informarles que necesitan activar su cuenta.
        if !user.Activated {
            app.inactiveAccountResponse(w, r)
            return
        }

        // Llama al siguiente controlador en la cadena.
        next.ServeHTTP(w, r)
    })
}

Aquí se nota que nuestro middleware requireActivatedUser() tiene una firma ligeramente diferente a la de los otros middlewares que hemos construido en este libro. En lugar de aceptar y devolver un http.Handler, acepta y devuelve un http.HandlerFunc.

Este es un pequeño cambio, pero hace posible envolver directamente nuestras funciones manejadoras de /v1/movie** con este middleware, sin necesidad de realizar más conversiones.

Adelante, actualiza el archivo cmd/api/routes.go para hacer exactamente eso, como sigue:

package main

func (app *application) routes() http.Handler {
    router := httprouter.New()
    router.NotFound = http.HandlerFunc(app.notFoundResponse)
    router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
    router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
    
    // Usa el middleware requireActivatedUser() en nuestros cinco endpoints /v1/movies**
    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))
    
    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)
    
    return app.recoverPanic(app.rateLimit(app.authenticate(router)))
}

16.1 Demostración

Comencemos llamando al endpoint GET /v1/movies/:id como un usuario anónimo. Cuando hagamos esto, deberías recibir ahora una respuesta 401 No autorizado, así:

$ curl -i localhost:4000/v1/movies/1
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Vary: Authorization
Www-Authenticate: Bearer
Date: Fri, 16 Apr 2021 15:59:33 GMT
Content-Length: 66
{
"error": "you must be authenticated to access this resource"
}

A continuación, intentemos hacer una solicitud como un usuario que tiene una cuenta pero aún no la ha activado. Si has estado siguiendo, deberías poder usar el usuario alice@example.com para hacer esto, así:

$ BODY='{"email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "2O4YHHWDHVVWWDNKN2UZR722BU",
"expiry": "2021-04-17T18:03:09.598843181+02:00"
}
}
$ curl -i -H "Authorization: Bearer 2O4YHHWDHVVWWDNKN2UZR722BU" localhost:4000/v1/movies/1
HTTP/1.1 403 Forbidden
Content-Type: application/json
Vary: Authorization
Date: Fri, 16 Apr 2021 16:03:45 GMT
Content-Length: 76
{
"error": "your user account must be activated to access this resource"
}

Genial, podemos ver que esto ahora resulta en una respuesta 403 Prohibido de nuestro nuevo ayudante inactiveAccountResponse().

Finalmente, intentemos hacer una solicitud como un usuario activado.

Si estás codificando en tiempo real, quizás quieras conectarte rápidamente a tu base de datos PostgreSQL y verificar qué usuarios ya están activados.

$ psql $GREENLIGHT_DB_DSN
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
greenlight=> SELECT email FROM users WHERE activated = true;
email
-------------------
faith@example.com
(1 row)

En mi caso, el único usuario activado es faith@example.com, así que intentemos hacer una solicitud como ella. Cuando hagamos una solicitud como un usuario activado, todas las verificaciones en nuestro middleware requireActivatedUser() pasarán y deberías recibir una respuesta exitosa. Similar a esto:

$ BODY='{"email": "faith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "ZFIKQ344EYM5KEP6JL2RHLRPJQ",
"expiry": "2021-04-17T18:04:57.513348573+02:00"
}
}
$ curl -H "Authorization: Bearer ZFIKQ344EYM5KEP6JL2RHLRPJQ" localhost:4000/v1/movies/1
{
"movie": {
"id": 1,
"title": "Moana",
"year": 2016,
"runtime": "107 mins",
"genres": [
"animation",
"adventure"
],
"version": 1
}
}

16.1 Dividendo el middleware

Por supuesto, aquí tienes el texto separado en párrafos:

En este momento tenemos un middleware que realiza dos verificaciones: primero verifica que el usuario esté autenticado (no sea anónimo), y segundo verifica que esté activado. Pero es posible imaginar un escenario en el que solo quieras verificar que un usuario esté autenticado y no te importe si está activado o no.

Para ayudar con esto, es posible que desees introducir un middleware adicional llamado “requireAuthenticatedUser()además del middleware actualrequireActivatedUser()`. Sin embargo, habría cierta superposición entre estos dos middlewares, ya que ambos verificarían si un usuario está autenticado o no.

Una forma elegante de evitar esta duplicación es hacer que tu middleware requireActivatedUser() llame automáticamente al middleware requireAuthenticatedUser(). Es difícil describir con palabras cómo funciona este patrón, así que lo demostraré. Si estás siguiendo, actualiza tu archivo cmd/api/middleware.go de la siguiente manera:

package main

// Create a new requireAuthenticatedUser() middleware to check that a user is not
// 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)
}

La forma en que hemos configurado esto es que nuestro middleware requireActivatedUser() ahora llama automáticamente al middleware requireAuthenticatedUser() antes de ejecutarse a sí mismo. En nuestra aplicación, esto tiene mucho sentido: no deberíamos verificar si un usuario está activado a menos que sepamos exactamente quién es.

Puedes seguir adelante y ejecutar la aplicación de nuevo ahora: todo debería compilar y seguir funcionando igual que antes.

16.1 Información adicional

16.1 Verificaciones dentro del controlador

Si solo tienes un par de puntos finales donde deseas realizar verificaciones de autorización, en lugar de utilizar middleware, a menudo es más fácil hacer las verificaciones dentro de los controladores relevantes. Por ejemplo:

func (app *application) exampleHandler(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
    }

    // El resto de la lógica del controlador va aquí...
}

16.2 Configurando la tabla de la base de datos de permisos.

Restringir nuestra API para que los datos de películas solo puedan ser accedidos y editados por usuarios activados es útil, pero a veces puede necesitar un nivel más granular de control. Por ejemplo, en nuestro caso podríamos estar contentos con que los usuarios “regulares” de nuestra API lean los datos de películas (siempre y cuando estén activados), pero queremos restringir el acceso de escritura a un subconjunto más pequeño de usuarios de confianza.

En este capítulo vamos a introducir el concepto de permisos en nuestra aplicación, de modo que solo los usuarios que tengan un permiso específico puedan realizar operaciones específicas. En nuestro caso, vamos a crear dos permisos: un permiso “movies:read” que permitirá a un usuario obtener y filtrar películas, y un permiso “movies:write” que permitirá a los usuarios crear, editar y eliminar películas.

Los permisos requeridos se alinearán con nuestros puntos finales de API de la siguiente manera:

MethodURL PatternRequired permission
GET/v1/healthcheck
GET/v1/moviesmovies:read
POST/v1/moviesmovies:write
GET/v1/movies/:idmovies:read
PATCH/v1/movies/:idmovies:write
DELETE/v1/movies/:idmovies:write
POST/v1/users
PUT/v1/users/activated
POST/v1/tokens/authentication

16.2 La relación entre permisos y usuarios

La relación entre permisos y usuarios es un gran ejemplo de una relación de muchos a muchos. Un usuario puede tener muchos permisos, y el mismo permiso puede pertenecer a muchos usuarios.

La forma clásica de gestionar una relación de muchos a muchos en una base de datos relacional como PostgreSQL es crear una tabla de unión entre las dos entidades. Voy a explicar rápidamente cómo funciona esto.

Digamos que estamos almacenando nuestros datos de usuario en una tabla llamada users que se ve así:

idemail
1alice@example.com
2bob@example.com

Y nuestros datos de permisos se almacenan en una tabla de permisos como esta:

user_idpermission_id
11
21
22

Entonces podemos crear una tabla de unión llamada users_permissions para almacenar la información sobre qué usuarios tienen qué permisos, similar a esto:

user_idpermission_id
11
21
22

En el ejemplo anterior, el usuario __alice@example.com (ID de usuario 1)__ tiene solamente el permiso movies:read (ID de permiso 1), mientras que __bob@example.com (ID de usuario 2)__ tiene tanto los permisos movies:read como movies:write.

Al igual que la relación uno a muchos que analizamos anteriormente en el libro, es posible que desee consultar esta relación desde ambos lados en los modelos de su base de datos. Por ejemplo, en sus modelos de base de datos, es posible que desee crear los siguientes métodos:

PermissionModel.GetAllForUser(user) → Retrieve all permissions for a user
UserModel.GetAllForPermission(permission) → Retrieve all users with a specific permission

16.3 Creando las migraciones SQL

Pongamos esto en práctica y hagamos una migración SQL que cree nuevas tablas de permisos (permissions) y usuarios-permisos (users_permissions) en nuestra base de datos, siguiendo el patrón que acabamos de describir anteriormente.

Adelante y ejecuta el siguiente comando para crear los archivos de migración:

$ migrate create -seq -ext .sql -dir ./migrations add_permissions
/Projects/greenlight/migrations/000006_add_permissions.up.sql
/greenlight/migrations/000006_add_permissions.down.sql

Y luego agrega las siguientes declaraciones SQL al archivo de migración ‘up’:

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)
);

-- Agregar los dos permisos a la tabla.
INSERT INTO permissions (code)
VALUES
    ('movies:read'),
    ('movies:write');

Aquí hay un par de cosas importantes que señalar aquí:

La línea PRIMARY KEY (user_id, permission_id) establece una clave primaria compuesta e n nuestra tabla users_permissions, donde la clave primaria está compuesta por las columnas user_id y permission_id. Al establecer esto como la clave primaria, básicamente significa que la misma combinación de usuario/permiso solo puede aparecer una vez en la tabla y no puede duplicarse.

Al crear la tabla users_permissions, usamos la sintaxis REFERENCES user para crear una restricción de clave externa contra la clave primaria de nuestra tabla users, lo que garantiza que cualquier valor en la columna user_id tenga una entrada correspondiente en nuestra tabla users. Y del mismo modo, usamos la sintaxis REFERENCES permissions para garantizar que la columna permission_id tenga una entrada correspondiente en la tabla permissions.

También agreguemos las declaraciones necesarias DROP TABLE al archivo de migración ‘down’, de la siguiente manera:

$ migrate -path ./migrations -database $GREENLIGHT_DB_DSN up
6/u add_permissions (22.74009ms)

16.3 Configurando permisos en el modelo

A continuación, diríjamonos a nuestro paquete internal/data y agreguemos un PermissionModel para gestionar las interacciones con nuestras nuevas tablas. Por ahora, lo único que queremos incluir en este modelo es un método GetAllForUser() para devolver todos los códigos de permiso para un usuario específico. La idea es que podamos usar esto en nuestros controladores y middleware de la siguiente manera:

// Return a slice of the permission codes for the user with ID = 1. This would return
// something like []string{"movies:read", "movies:write"}.
app.models.Permissions.GetAllForUser(1)

Tras bastidores, la declaración SQL que necesitamos para obtener los códigos de permiso de un usuario específico se ve así:

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

En esta consulta estamos utilizando la cláusula INNER JOIN para unir nuestra tabla de permisos con nuestra tabla de usuarios_permisos, y luego usando nuevamente esta unión para unirla a la tabla de usuarios. Luego utilizamos la cláusula WHERE para filtrar el resultado, dejando solo las filas que se relacionan con un ID de usuario específico.

Adelante, configuremos el nuevo PermissionModel. Primero, crea un nuevo archivo llamado permissions.go dentro de internal/data/.

$ touch internal/data/permissions.go

Y luego añade el siguiente código:

package data

import (
    "context"
    "database/sql"
    "time"
)

// Define a Permissions slice, which we will use to 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
}

// Define the PermissionModel type.
type PermissionModel struct {
    DB *sql.DB
}

// The 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
}

Entonces, lo último que necesitamos hacer es agregar el PermissionModel a nuestra estructura Model principal, para que esté disponible para nuestros controladores y middleware. Asi:

package data

type Models struct {
    Movies           MovieModel
    MovieModel       MovieModel
    Permissions      PermissionModel // Agregar un nuevo campo Permissions.
    TokensTokenModel TokensTokenModel
    UsersUserModel   UsersUserModel
}

func NewModels(db *sql.DB) Models {
    return Models{
        Movies:       MovieModel{DB: db},
        Permissions:  PermissionModel{DB: db}, // Inicializar una nueva instancia de PermissionModel.
        Tokens:TokenModel{DB: db},
        Users:UserModel{DB: db},
    }
}

16.4 Revisando permisos

Claro, aquí tienes el texto separado en párrafos:


Ahora que nuestro PermissionModel está configurado, veamos cómo podemos usarlo para restringir el acceso a nuestros puntos finales de API.

Conceptualmente, lo que necesitamos hacer aquí no es demasiado complicado. Crearemos un nuevo middleware requirePermission() que acepte un código de permiso específico como “movies:read” como argumento.

En este middleware, recuperaremos el usuario actual del contexto de la solicitud y llamaremos al método app.models.Permissions.GetAllForUser() (que acabamos de hacer) para obtener un fragmento de sus permisos.

Luego podemos verificar si el fragmento contiene el código de permiso específico necesario. Si no lo hace, deberíamos enviar al cliente una respuesta 403 Forbidden.

Para poner esto en práctica, primero hagamos una nueva función auxiliar notPermittedResponse() para enviar la respuesta 403 Forbidden. Sería algo así:

package main
...
  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)
}

Entonces, diríjamonos a nuestro archivo cmd/api/middleware.go y creemos el nuevo método middleware requirePermission(). Vamos a configurarlo para que el middleware requirePermission() envuelva automáticamente nuestro middleware existente requireActivatedUser(), que a su vez — no lo olvides — envuelve nuestro middleware requireAuthenticatedUser(). Esto es importante; significa que cuando usemos el middleware requirePermission(), en realidad estaremos realizando tres comprobaciones que juntas aseguran que la solicitud proviene de un usuario autenticado (no anónimo) y activado, que tiene un permiso específico.

Vamos a proceder y crear esto en el archivo cmd/api/middleware.go de la siguiente manera:

package main

// 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)
}

Una vez hecho eso, el último paso es actualizar nuestro archivo cmd/api/routes.go para utilizar el nuevo middleware en los puntos finales necesarios.

Adelante y actualiza las rutas para que nuestra API requiera el permiso “movies:read” para los puntos finales que obtienen datos de películas, y el permiso “movies:write” para los puntos finales que crean, editan o eliminan una película.

package main

func (app *application) routes() http.Handler {
    router := httprouter.New()
    router.NotFound = http.HandlerFunc(app.notFoundResponse)
    router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
    router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)

    // 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)

    return app.recoverPanic(app.rateLimit(app.authenticate(router)))
}

16.4 Demostración

Mostrar esto en acción es un poco incómodo porque, si has estado siguiendo, ninguno de los usuarios en nuestra base de datos actualmente tiene permisos configurados para ellos. Para ayudar a demostrar esta nueva funcionalidad, abramos psql y agreguemos algunos permisos. Específicamente, haremos lo siguiente:

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight:
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
greenlight=>

Y ejecutar las siguientes declaraciones:

-- Set the activated field for alice@example.com to true.
UPDATE users SET activated = true WHERE email = 'alice@example.com';
-- Give all users the 'movies:read' permission
INSERT INTO users_permissions
SELECT id, (SELECT id FROM permissions WHERE code = 'movies:read') FROM users;
-- Give faith@example.com the 'movies:write' permission
INSERT INTO users_permissions
VALUES (
(SELECT id FROM users WHERE email = 'faith@example.com'),
(SELECT id FROM permissions WHERE
code = 'movies:write')
);
-- List all activated users and their permissions.
SELECT email, array_agg(permissions.code) as permissions
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.activated = true
GROUP BY email;

Una vez que se complete, deberías ver una lista de los usuarios actualmente activados y sus permisos, similar a esto:

emailpermissions
alice@example.com{movies:read}
faith@example.com{movies:read,movies:write}

Nota: En esa consulta SQL final, estamos utilizando la función de agregación array_agg() y una cláusula GROUP BY para mostrar los permisos asociados con cada dirección de correo electrónico como un arreglo.

Ahora que nuestros usuarios tienen algunos permisos asignados, estamos listos para probar esto. Para empezar, intentemos hacer algunas solicitudes como alice@example.com a nuestros puntos finales GET /v1/movies/1 y DELETE /v1/movies/1. La primera solicitud debería funcionar correctamente, pero la segunda debería fallar porque el usuario no tiene el permiso necesario movies:write.

$ BODY='{"email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
  "authentication_token": {
    "token": "OPFXEPOYZWMGNWXWKMYIMEGATU",
    "expiry": "2021-04-17T20:49:39.963768416+02:00"
  }
}
$ curl -H "Authorization: Bearer OPFXEPOYZWMGNWXWKMYIMEGATU" localhost:4000/v1/movies/1
{
  "movie": {
    "id": 1,
    "title": "Moana",
    "year": 2016,
    "runtime": "107 mins",
    "genres": [
      "animation",
      "adventure"
    ],
    "version": 1
  }
}
$ curl -X DELETE -H "Authorization: Bearer OPFXEPOYZWMGNWXWKMYIMEGATU" localhost:4000/v1/movies/1
{
  "error": "your user account doesn't have the necessary permissions to access this resource"
}

Genial, eso está funcionando tal como esperábamos: la operación DELETE está bloqueada porque alice@example.com no tiene el permiso necesario movies:write.

En contraste, intentemos la misma operación pero con faith@example.com como usuario. Esta vez, la operación DELETE debería funcionar correctamente, así:

$ BODY='{"email": "faith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
  "authentication_token": {
    "token": "E42XD5OBBBO4MPUPYGLLY2GURE",
    "expiry": "2021-04-17T20:51:14.924813208+02:00"
  }
}
$ curl -X DELETE -H "Authorization: Bearer E42XD5OBBBO4MPUPYGLLY2GURE" localhost:4000/v1/movies/1
{
  "message": "movie successfully deleted"
}

16.5 Concediendo Permisos

Nuestro modelo de permisos y middleware de autorización ahora están funcionando bien. Pero, en este momento, cuando un nuevo usuario registra una cuenta, no tiene ningún permiso. En este capítulo, vamos a cambiar eso para que los nuevos usuarios reciban automáticamente el permisomovies:read” por defecto.

16.5 Actualizando el modelo de permisos

Para otorgar permisos a un usuario, necesitaremos actualizar nuestro Modelo de Permisos para incluir un método AddForUser() , que añade uno o más códigos de permiso para un usuario específico en nuestra base de datos . La idea es que podamos utilizarlo en nuestros manejadores de la siguiente manera:

// Add the "movies:read" and "movies:write" permissions for the user with ID = 2.
app.models.Permissions.AddForUser(2, "movies:read", "movies:write")

Tras bambalinas, la declaración SQL que necesitamos para insertar estos datos se ve así:

INSERT INTO users_permissions
SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)

En esta consulta, el parámetro $1 será el ID del usuario, y el parámetro $2 será un array de PostgreSQL con los códigos de permiso que queremos añadir para el usuario, como {'movies:read', 'movies:write'}.

Lo que está sucediendo aquí es que la declaración SELECT … en la segunda línea crea una tabla ‘intermedia’ con filas compuestas por el ID de usuario y los IDs correspondientes de los códigos de permiso en el array. Luego insertamos el contenido de esta tabla intermedia en nuestra tabla user_permissions.

Sigamos adelante y creemos el método AddForUser() en el archivo internal/data/permissions.go:

package data

import (
    "context"
    "database/sql"
    "time"

    "github.com/lib/pq" // Nuevo import
)

// AddForUser agrega los códigos de permiso proporcionados para un usuario específico.
// Observa que estamos utilizando un parámetro variádico para los códigos para que podamos
// asignar múltiples permisos en una sola llamada.
func (m PermissionModel) AddForUser(userID int64, codes ...string) error {
    query := `
        INSERT INTO users_permissions
        SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)
    `
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    _, err := m.DB.ExecContext(ctx, query, userID, pq.Array(codes))
    return err
}

16.5 Actualizando el manejador de registro:

Ahora que eso está en su lugar, actualicemos nuestro manejador de registro para que los nuevos usuarios reciban automáticamente el permiso “movies:read” cuando se registren. Sería algo así:

package main

import (
    // Importaciones
)

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
    }

    err = app.models.Users.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
    }

    // Agregar el permiso "movies:read" para el nuevo usuario.
    err = app.models.Permissions.AddForUser(user.ID, "movies:read")
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    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() {
        data := map[string]interface{}{
            "activationToken": token.Plaintext,
            "userID":          user.ID,
        }
        err = app.mailer.Send(user.Email, "user_welcome.tmpl", data)
        if err != nil {
            app.logger.Error(err.Error())
        }
    })

    err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

Vamos a verificar que esto esté funcionando correctamente registrando un nuevo usuario con la dirección de correo electrónico grace@example.com:

$ BODY='{"name": "Grace Smith", "email": "grace@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
  "user": {
    "id": 8,
    "created_at": "2021-04-16T21:32:56+02:00",
    "name": "Grace Smith",
    "email": "grace@example.com",
    "activated": false
  }
}

Si abres psql, deberías poder ver que tienen el permiso “movies:read” ejecutando la siguiente consulta SQL:

SELECT email, code FROM users
INNER JOIN users_permissions ON users.id = users_permissions.user_id
INNER JOIN permissions ON users_permissions.permission_id = permissions.id
WHERE users.email = 'grace@example.com';

Asi:

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight:
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
greenlight=> SELECT email, code FROM users
greenlight-> INNER JOIN users_permissions ON users.id = users_permissions.user_id
greenlight-> INNER JOIN permissions ON users_permissions.permission_id = permissions.id
greenlight-> WHERE users.email = 'grace@example.com';
email
|
code
-------------------+-------------
grace@example.com | movies:read
(1 row)

Post Relacionados