14. Activacion de usuario

En este momento, un usuario puede registrarse para obtener una cuenta con nuestra API de Greenlight, pero no sabemos con certeza si la dirección de correo electrónico que proporcionaron durante el registro realmente les pertenece.

Entonces vamos a desarrollar la funcionalidad para confirmar que un usuario utilizó su propia dirección de correo electrónico real, incluyendo instrucciones de ‘activación de cuenta’ en su correo de bienvenida.

Hay varias razones para tener un paso de activación, pero los principales beneficios son que agrega un obstáculo adicional para que los bots lo superen y ayuda a prevenir el abuso por parte de personas que se registran con una dirección de correo electrónico falsa o que no les pertenece. Para darte una visión general desde el principio, el proceso de activación de cuenta funcionará de la siguiente manera:

  1. Como parte del proceso de registro para un nuevo usuario, crearemos un token de activación aleatorio criptográficamente seguro que es imposible de adivinar.
  2. Luego almacenaremos un hash de este token de activación en una nueva tabla de tokens, junto con el ID del nuevo usuario y un tiempo de vencimiento para el token.
  3. Enviaremos el token de activación original (sin hash) al usuario en su correo de bienvenida.
  4. El usuario posteriormente envía su token a un nuevo endpoint PUT /v1/users/activated.
  5. Si el hash del token existe en la tabla de tokens y no ha caducado, actualizaremos el estado de activación del usuario correspondiente a true.
  6. Por último, eliminaremos el token de activación de nuestra tabla de tokens para que no pueda ser utilizado nuevamente.

En esta seccion, aprenderas como :

  1. Implementar un flujo de trabajo seguro de ‘activación de cuenta’ que verifica la dirección de correo electrónico de un nuevo usuario.
  2. Generar tokens aleatorios criptográficamente seguros utilizando los paquetes crypto/rand y encoding/base32 de Go.
  3. Generar hashes rápidos de datos utilizando el paquete crypto/sha256.
  4. Implementar patrones para trabajar con relaciones entre tablas en tu base de datos, incluyendo la configuración de claves foráneas y la obtención de datos relacionados mediante consultas SQL JOIN.

14.1 Configurando los tokens en la tabla de la DB

Comencemos creando una nueva tabla de tokens en nuestra base de datos para almacenar los tokens de activación de nuestros usuarios. Si estás siguiendo, ejecuta el siguiente comando para crear un nuevo par de archivos de migración:

$ migrate create -seq -ext .sql -dir ./migrations create_tokens_table
/nahueldev23.github.com/migrations/000005_create_tokens_table.up.sql
/nahueldev23.github.com/greenlight/migrations/000005_create_tokens_table.down.sql

Y luego agrega las siguientes declaraciones SQL a los archivos de migración ‘up’ y ‘down’, respectivamente:

CREATE TABLE IF NOT EXISTS tokens (
    hash bytea PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
    expiry timestamp(0) with time zone NOT NULL,
    scope text NOT NULL
);
DROP TABLE IF EXISTS tokens;

Veamos rapidamente las columnas de la nueva tabla tokens para explicar sus propositos:

  • La columna hash contendrá un hash SHA-256 del token de activación. Es importante enfatizar que solo almacenaremos un hash del token de activación en nuestra base de datos, no el token de activación en sí.

Queremos hashear el token antes de almacenarlo por la misma razón por la que usamos bcrypt para la contraseña del usuario: proporciona una capa adicional de protección si la base de datos se ve comprometida o se filtra. Dado que nuestro token de activación va a ser una cadena aleatoria de alta entropía (128 bits), en lugar de algo de baja entropía como una contraseña de usuario típica, es suficiente utilizar un algoritmo rápido como SHA-256 para crear el hash, en lugar de un algoritmo lento como bcrypt.

  • La columna user_id contendrá el ID del usuario asociado con el token. Utilizamos la sintaxis REFERENCES user para crear una restricción de clave externa (foreign key) contra la clave primaria de nuestra tabla users, lo que asegura que cualquier valor en la columna user_id tenga una entrada correspondiente en nuestra tabla users. También utilizamos la sintaxis ON DELETE CASCADE para instruir a PostgreSQL que elimine automáticamente todos los registros para un usuario en nuestra tabla tokens cuando el registro principal en la tabla users sea eliminado.

Nota: Una alternativa común a ON DELETE CASCADE es ON DELETE RESTRICT, que en nuestro caso evitaría que se eliminara un registro principal en la tabla users si el usuario tiene algún token en nuestra tabla tokens. Si utilizas ON DELETE RESTRICT, deberías eliminar manualmente cualquier token para el usuario antes de eliminar el propio registro del usuario.

  • La columna expiry contendrá la hora que consideramos que un token está ‘caducado’ y ya no es válido. Establecer un tiempo de expiración corto es beneficioso desde el punto de vista de la seguridad porque ayuda a reducir la ventana de posibilidad para un ataque de fuerza bruta exitoso contra el token. También es útil en el escenario donde se envía un token al usuario pero no lo utiliza, y su cuenta de correo electrónico se ve comprometida en un momento posterior. Al establecer un límite de tiempo corto, se reduce la ventana de tiempo en la que el token comprometido podría ser utilizado.

Por supuesto, los riesgos de seguridad aquí deben sopesarse frente a la usabilidad, y queremos que el tiempo de expiración sea lo suficientemente largo para que un usuario pueda activar la cuenta a su ritmo. En nuestro caso, estableceremos el tiempo de expiración para nuestros tokens de activación en 3 días a partir del momento en que se creó el token.

  • Por último, la columna scope indicará con qué propósito se puede utilizar el token. Más adelante en el post también necesitaremos crear y almacenar tokens de autenticación, y la mayor parte del código y los requisitos de almacenamiento para estos son exactamente los mismos que para nuestros tokens de activación. Entonces, en lugar de crear tablas separadas (y el código para interactuar con ellas), los almacenaremos en una tabla con un valor en la columna scope para restringir el propósito para el que se puede utilizar el token.

De acuerdo, con esas explicaciones aclaradas, deberías poder ejecutar la migración ‘up’ con el siguiente comando:

//postgres://greenlight:pa55word@localhost/greenligh
$ migrate -path=./migrations -database=$GREENLIGHT_DB_DSN up
5/u create_tokens_table (21.568194ms)

14.2 Creando Activacion segura para Tokens

La integridad de nuestro proceso de activación depende de una cosa clave: la ‘inadivinabilidad’ del token que enviamos a la dirección de correo electrónico del usuario. Si el token es fácil de adivinar o puede ser objeto de un ataque de fuerza bruta, sería posible que un atacante activara la cuenta de un usuario incluso si no tiene acceso a la bandeja de entrada del correo electrónico del usuario.

Debido a esto, queremos que el token sea generado por un generador de números aleatorios criptográficamente seguro (CSPRNG, por sus siglas en inglés) y tenga suficiente entropía (o aleatoriedad) como para que sea imposible de adivinar. En nuestro caso, crearemos nuestros tokens de activación utilizando el paquete crypto/rand de Go y 128 bits (16 bytes) de entropía.

Si estás siguiendo, continúa y crea un nuevo archivo internal/data/tokens.go. Este actuará como el hogar de toda nuestra lógica relacionada con la creación y gestión de tokens en los próximos capítulos.

$ touch internal/data/tokens.go

Luego, en este archivo, definamos una estructura Token (para representar los datos de un token individual) y una función generateToken() que podamos utilizar para crear un nuevo token. Este es otro momento en el que probablemente sea más fácil sumergirse directamente en el código y describir lo que está sucediendo a medida que avanzamos.

package data

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base32"
	"time"
)

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

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

func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) {
	// Create a Token instance containing the user ID, expiry, and scope information.
	// Notice that we add the provided ttl (time-to-live) duration parameter to the
	// current time to get the expiry time?
	token := &Token{
		UserID: userID,
		Expiry: time.Now().Add(ttl),
		Scope:  scope,
	}

	// Initialize a zero-valued byte slice with a length of 16 bytes.
	randomBytes := make([]byte, 16)

	// Use the Read() function from the crypto/rand package to fill the byte slice with
	// random bytes from your operating system's CSPRNG. This will return an error if
	// the CSPRNG fails to function correctly.
	_, err := rand.Read(randomBytes)
	if err != nil {
		return nil, err
	}

	// Encode the byte slice to a base-32-encoded string and assign it to the token
	// Plaintext field. This will be the token string that we send to the user in their
	// welcome email. They will look similar to this:
	//
	// Y3QMGX3PJ3WLRL2YRTQGQ6KRHU
	//
	// Note that by default base-32 strings may be padded at the end with the =
	// character. We don't need this padding character for the purpose of our tokens, so
	// we use the WithPadding(base32.NoPadding) method in the line below to omit them.
	token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)

	// Generate a SHA-256 hash of the plaintext token string. This will be the value
	// that we store in the `hash` field of our database table. Note that the
	// sha256.Sum256() function returns an *array* of length 32, so to make it easier to
	// work with we convert it to a slice using the [:] operator before storing it.
	hash := sha256.Sum256([]byte(token.Plaintext))
	token.Hash = hash[:]

	return token, nil
}

Es importante señalar que las cadenas de tokens en texto sin formato que estamos creando aquí, como Y3QMGX3PJ3WLRL2YRTQGQ6KRHU, no tienen 16 caracteres de longitud, sino que tienen una entropía subyacente de 16 bytes de aleatoriedad. La longitud de la cadena de texto sin formato del token en sí depende de cómo se codifiquen esos 16 bytes aleatorios para crear una cadena. En nuestro caso, codificamos los bytes aleatorios en una cadena de base-32, lo que resulta en una cadena con 26 caracteres. En contraste, si codificáramos los bytes aleatorios usando hexadecimal (base-16), la cadena tendría una longitud de 32 caracteres en su lugar.

14.2 Creando el TokenModel y las revisiones de validacion

Vamos a continuar configurando un tipo TokenModel que encapsule las interacciones con la base de datos PostgreSQL en nuestra tabla de tokens. Seguiremos un patrón muy similar al MovieModel y UsersModel nuevamente, e implementaremos los siguientes tres métodos en él:

  • Insert(): para insertar un nuevo registro de token en la base de datos.
  • New(): será un método abreviado que crea un nuevo token utilizando la función generateToken() y luego llama a Insert() para almacenar los datos.
  • DeleteAllForUser(): para eliminar todos los tokens con un ámbito específico para un usuario específico.

También crearemos una nueva función ValidateTokenPlaintext(), que verificará que un token en texto plano proporcionado por un cliente en el futuro tenga exactamente 26 bytes de longitud. Abre nuevamente el archivo internal/data/tokens.go y agrega el siguiente código:

package data

import (
	"context" // New import
	"crypto/rand"
	"crypto/sha256"
	"database/sql" // New import
	"encoding/base32"
	"time"
	"nahueldev23.github.com/internal/validator" // New import
)

...

// Check that the plaintext token has been provided and is exactly 26 bytes long.
func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) {
	v.Check(tokenPlaintext != "", "token", "must be provided")
	v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
}

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

// The New() method is a shortcut which creates a new Token struct and then inserts the
// data in the tokens table.
func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) {
	token, err := generateToken(userID, ttl, scope)
	if err != nil {
		return nil, err
	}
	err = m.Insert(token)
	return token, err
}

// Insert() adds the data for a specific token to the tokens table.
func (m TokenModel) Insert(token *Token) error {
	query := `
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4)`
	args := []interface{}{token.Hash, token.UserID, token.Expiry, token.Scope}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	_, err := m.DB.ExecContext(ctx, query, args...)
	return err
}

// DeleteAllForUser() deletes all tokens for a specific user and scope.
func (m TokenModel) DeleteAllForUser(scope string, userID int64) error {
	query := `
DELETE FROM tokens
WHERE scope = $1 AND user_id = $2`
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	_, err := m.DB.ExecContext(ctx, query, scope, userID)
	return err
}

Y finalmente, necesitamos actualizar el archivo models.go en la carpeta internal/data para que el nuevo TokenModel esté incluido en nuestra estructura principal Models. De la siguiente manera:

package data

// ...

type Models struct {
	Movies MovieModel
	Tokens TokenModel // Agrega un nuevo campo Tokens.
	Users  UserModel
}

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

En este punto, deberías poder reiniciar la aplicación y todo debería funcionar sin problemas.

$ go run ./cmd/api/
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development

14.2 Informacion adicional

14.2 Paquete math/rand

Go también cuenta con un paquete llamado math/rand que proporciona un generador de números pseudoaleatorios (PRNG) determinista. Es crucial que nunca uses el paquete math/rand para ningún propósito en el que se requiera seguridad criptográfica, como la generación de tokens o secretos, como estamos haciendo aquí.

De hecho, lo más recomendable es utilizar crypto/rand como práctica estándar. Solo opta por usar math/rand en escenarios específicos donde estés seguro de que un PRNG determinista es aceptable y necesitas activamente el rendimiento más rápido que proporciona math/rand.

14.3 Enviando tokens de activacion

El siguiente paso es conectar esto con nuestro registerUserHandler, de modo que generemos un token de activación cuando un usuario se registra e incluirlo en su correo de bienvenida, de manera similar a esto:

Hi,
Thanks for signing up for a Greenlight account. We're excited to have you on board!
For future reference, your user ID number is 123.
Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON
body to activate your account:

{"token": "Y3QMGX3PJ3WLRL2YRTQGQ6KRHU"}

Please note that this is a one-time use token and it will expire in 3 days.
Thanks,
The Greenlight Team

Lo más importante de este correo electrónico es que estamos instruyendo al usuario a activarse emitiendo una solicitud PUT a nuestra API, en lugar de hacer clic en un enlace que contiene el token como parte de la ruta URL o la cadena de consulta.

Hacer que un usuario haga clic en un enlace para activarse a través de una solicitud GET (que se utiliza de forma predeterminada al hacer clic en un enlace) ciertamente sería más conveniente, pero en el caso de nuestra API tiene algunas desventajas significativas. En particular:

  • Esto violaría el principio HTTP de que el método GET solo debería utilizarse para solicitudes “seguras” que recuperan recursos, no para solicitudes que modifican algo, como el estado de activación de un usuario.

  • Es posible que el navegador web del usuario o el antivirus pre-cargue la URL del enlace en segundo plano, activando la cuenta inadvertidamente. Este comentario en Stack Overflow explica bien el riesgo de esto:

Esto podría dar lugar a un escenario en el que un actor malintencionado (Eva) desea crear una cuenta utilizando el correo electrónico de otra persona (Alicia). Eva se registra, y Alicia recibe un correo electrónico. Alicia abre el correo porque está curiosa acerca de una cuenta que no solicitó. Su navegador (o antivirus) solicita la URL en segundo plano, activando inadvertidamente la cuenta.

En resumen, debes asegurarte de que cualquier acción que cambie el estado de tu aplicación (incluida la activación de un usuario) se realice solo mediante solicitudes POST, PUT, PATCH o DELETE, y no mediante solicitudes GET.

Nota: Si tu API es la parte trasera (backend) de un sitio web, podrías adaptar este correo electrónico para que pida al usuario que haga clic en un enlace que los lleve a una página en tu sitio web. Luego, pueden hacer clic en un botón en la página para ‘confirmar su activación’, lo que realiza la solicitud PUT a tu API que activa realmente al usuario. Examinaremos este patrón con más detalle en el próximo capítulo.

Pero por ahora, si estás siguiendo el proceso, continúa y actualiza tus plantillas de correo de bienvenida para incluir el token de activación de la siguiente manera:

Aquí está el código formateado para las plantillas de correo de bienvenida:

{{define "subject"}}Welcome to Greenlight!{{end}}

{{define "plainBody"}}
Hi,
Thanks for signing up for a Greenlight account. We're excited to have you on board!
For future reference, your user ID number is {{.userID}}.
Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON
body to activate your account:
{"token": "{{.activationToken}}"}
Please note that this is a one-time use token and it will expire in 3 days.
Thanks,
The Greenlight Team
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
<p>For future reference, your user ID number is {{.userID}}.</p>
<p>Please send a request to the <code>PUT /v1/users/activated</code> endpoint with the
following JSON body to activate your account:</p>
<pre><code>
{"token": "{{.activationToken}}"}
</code></pre>
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
<p>Thanks,</p>
<p>The Greenlight Team</p>
</body>
</html>
{{end}}

Esta es una plantilla de correo electrónico que incluye información sobre la cuenta y las instrucciones para activarla mediante una solicitud PUT a la API.

A continuación, necesitaremos actualizar el registerUserHandler para generar un nuevo token de activación y pasarlo como datos dinámicos a la plantilla del correo de bienvenida, junto con el ID de usuario. De la siguiente manera:

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
	// ...

	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
	}

	// Después de que se haya creado el registro del usuario en la base de datos, genera un nuevo token de activación para el usuario.
	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() {
		// Dado que ahora hay múltiples datos que queremos pasar a nuestras plantillas de correo,
		// creamos un mapa para actuar como una 'estructura contenedora' para los datos. Esto
		// contiene la versión de texto sin formato del token de activación para el usuario, junto
		// con su ID.
		data := map[string]interface{}{
			"activationToken": token.Plaintext,
			"userID":          user.ID,
		}

		// Envía el correo de bienvenida, pasando el mapa de arriba como datos dinámicos.
		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)
	}
}

Usuario Bien, veamos cómo funciona esto… Reinicia la aplicación y luego registra una nueva cuenta de usuario con la dirección de correo electrónico faith@example.com. Similar a esto:

$ BODY='{"name": "Faith Smith", "email": "faith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
  {
  "user": {
  "id": 7,
  "created_at": "2021-04-15T20:25:41+02:00",
  "name": "Faith Smith",
  "email": "faith@example.com",
  "activated": false
  }
}

Y si abres tu bandeja de entrada de Mailtrap nuevamente, ahora deberías ver el nuevo correo de bienvenida que contiene el token de activación para faith@example.com, de la siguiente manera:

$ 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 * FROM tokens;
                                hash                                | user_id |         expiry          |   scope
--------------------------------------------------------------------+---------+------------------------+------------
\x09bcb40206b25fe511bfef4d56cbe8c4a141869fc29612fa984b371ef086f5f5 |      7  | 2021-04-18 20:25:41+02 | activation

Aquí podemos ver que el valor del hash del token de activación se muestra como:

\x09bcb40206b25fe511bfef4d56cbe8c4a141869fc29612fa984b371ef086f5f5

Como mencionamos anteriormente, psql siempre muestra los valores en columnas bytea como una cadena codificada en hexadecimal. Entonces, lo que estamos viendo aquí es una codificación hexadecimal del hash SHA-256 del token en texto sin formato P4B3URJZJ2NW5UPZC2OHN4H2NM que enviamos en el correo de bienvenida.

Nota: Si lo deseas, puedes verificar que esto es correcto ingresando un token de activación en texto sin formato del correo de bienvenida en este generador de hash SHA-256 en línea. Deberías ver que el resultado coincide con el valor codificado en hexadecimal dentro de tu base de datos PostgreSQL.

Observa también que el valor de user_id 7 es correcto para nuestro usuario faith@example.com, el tiempo de expiración se ha establecido correctamente a tres días desde ahora, y el token tiene el valor de scope “activation”?

14.3 Informacion Adicional

14.3 Un punto de conexión independiente para generar Tokens

También puedes querer proporcionar un punto de conexión independiente para generar y enviar tokens de activación a tus usuarios. Esto puede ser útil si necesitas volver a enviar un token de activación, como cuando un usuario no activa su cuenta dentro del límite de tiempo de 3 días, o si nunca reciben su correo de bienvenida.

El código para implementar este punto de conexión es una combinación de patrones que ya hemos discutido.

14.4 Activando un usuario

En este capítulo, vamos a avanzar a la parte del flujo de activación donde activaremos realmente a un usuario. Pero antes de escribir cualquier código, me gustaría hablar brevemente sobre la relación entre usuarios y tokens en nuestro sistema.

Lo que tenemos se conoce en términos de base de datos relacionales como una relación uno a muchos (one-to-many) — donde un usuario puede tener muchos tokens, pero un token solo puede pertenecer a un usuario.

Cuando tienes una relación uno a muchos como esta, es posible que desees ejecutar consultas contra la relación desde dos lados diferentes. En nuestro caso, por ejemplo, podríamos querer:

  • Recuperar el usuario asociado con un token.
  • Recuperar todos los tokens asociados a un usuario.

Para implementar estas consultas en tu código, un enfoque limpio y claro es actualizar tus modelos de base de datos para incluir algunos métodos adicionales, como este:

UserModel.GetForToken(token) → Retrieve the user associated with a token
TokenModel.GetAllForUser(user) → Retrieve all tokens associated with a user

Lo bueno de este enfoque es que las entidades que se devuelven se alinean con la responsabilidad principal de los modelos: el método UserModel está devolviendo un usuario, y el método TokenModel está devolviendo tokens.

14.4 Creando activateUserHandler

Ahora que tenemos una idea muy general de cómo vamos a consultar la relación usuario ↔ token en nuestros modelos de base de datos, comencemos a construir el código para activar a un usuario.

Para hacer esto, necesitaremos agregar un nuevo punto de conexión PUT /v1/users/activated a nuestra API:

Aquí tienes la tabla en formato Markdown:

MétodoURL PatternHandlerAcción
GET/v1/healthcheckhealthcheckHandlerMostrar información de la aplicación
GET/v1/movieslistMoviesHandlerMostrar detalles de todas las películas
POST/v1/moviescreateMovieHandlerCrear una nueva película
GET/v1/movies/:idshowMovieHandlerMostrar detalles de una película específica
PATCH/v1/movies/:idupdateMovieHandlerActualizar detalles de una película específica
DELETE/v1/movies/:iddeleteMovieHandlerEliminar una película específica
POST/v1/usersregisterUserHandlerRegistrar un nuevo usuario
PUT/v1/users/activatedactivateUserHandlerActivar a un usuario específico

El flujo de trabajo se verá así:

  1. El usuario envía el token de activación en texto sin formato (que acaba de recibir en su correo electrónico) al punto de conexión PUT /v1/users/activated.
  2. Validamos el token en texto sin formato para comprobar que coincide con el formato esperado, enviando un mensaje de error al cliente si es necesario.
  3. Luego llamamos al método UserModel.GetForToken() para recuperar los detalles del usuario asociado con el token proporcionado. Si no se encuentra un token coincidente o ha caducado, enviamos un mensaje de error al cliente.
  4. Activamos al usuario asociado estableciendo activated = true en el registro del usuario y actualizándolo en nuestra base de datos.
  5. Eliminamos todos los tokens de activación para el usuario de la tabla de tokens. Podemos hacer esto utilizando el método TokenModel.DeleteAllForUser() que creamos anteriormente.
  6. Enviamos los detalles actualizados del usuario en una respuesta JSON.

Comencemos en nuestro archivo cmd/api/users.go y creemos el nuevo activateUserHandler para seguir estos pasos:

package main

// ...

func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
	// Parse the plaintext activation token from the request body.
	var input struct {
		TokenPlaintext string `json:"token"`
	}
	err := app.readJSON(w, r, &input)
	if err != nil {
		app.badRequestResponse(w, r, err)
		return
	}

	// Validate the plaintext token provided by the client.
	v := validator.New()
	if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}

	// Retrieve the details of the user associated with the token using the
	// GetForToken() method (which we will create in a minute). If no matching record
	// is found, then we let the client know that the token they provided is not valid.
	user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
	if err != nil {
		switch {
		case errors.Is(err, data.ErrRecordNotFound):
			v.AddError("token", "invalid or expired activation token")
			app.failedValidationResponse(w, r, v.Errors)
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}

	// Update the user's activation status.
	user.Activated = true

	// Save the updated user record in our database, checking for any edit conflicts in
	// the same way that we did for our movie records.
	err = app.models.Users.Update(user)
	if err != nil {
		switch {
		case errors.Is(err, data.ErrEditConflict):
			app.editConflictResponse(w, r)
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}

	// If everything went successfully, then we delete all activation tokens for the
	// user.
	err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}

	// Send the updated user details to the client in a JSON response.
	err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Si intentas compilar la aplicación en este momento, obtendrás un error porque el método UserModel.GetForToken() aún no existe. Vamos a crearlo ahora.

14.4 El metodo UserModel.GetForToken

Como mencionamos anteriormente, queremos que el método UserModel.GetForToken() recupere los detalles del usuario asociado con un token de activación específico. Si no se encuentra un token coincidente o ha caducado, queremos que esto devuelva un error ErrRecordNotFound en su lugar. Para lograrlo, necesitaremos ejecutar la siguiente consulta SQL en nuestra base de datos:

SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1
AND tokens.scope = $2
AND tokens.expiry > $3

Esto es más complicado que la mayoría de las consultas SQL que hemos utilizado hasta ahora, así que tomémonos un momento para explicar qué está haciendo.

En esta consulta, estamos utilizando INNER JOIN para unir información de las tablas users y tokens. Específicamente, estamos utilizando la cláusula ON users.id = tokens.user_id para indicar que queremos unir registros donde el valor del ID de usuario es igual al user_id del token. En términos prácticos, puedes pensar en INNER JOIN como la creación de una tabla ‘intermedia’ que contiene los datos unidos de ambas tablas. Luego, en nuestra consulta SQL, usamos la cláusula WHERE para filtrar esta tabla intermedia y dejar solo las filas donde el hash del token y el ámbito del token coinciden con valores específicos de parámetros de marcador de posición, y la caducidad del token es después de un tiempo específico. Debido a que el hash del token también es una clave primaria, siempre nos quedará exactamente un registro que contiene los detalles del usuario asociado con el hash del token (o ningún registro si no había un token coincidente).

Pista: Si no estás familiarizado con la realización de joins en SQL, este artículo ofrece una buena descripción general de los diferentes tipos de joins, cómo funcionan y algunos ejemplos que deberían ayudarte a comprenderlo.

Si estás siguiendo el proceso, abre tu archivo internal/data/users.go y agrega un método GetForToken() que ejecute esta consulta SQL de la siguiente manera:

func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) {
	// Calcular el hash SHA-256 del token en texto sin formato proporcionado por el cliente.
	// Recuerda que esto devuelve un *array* de bytes con longitud 32, no una slice.
	tokenHash := sha256.Sum256([]byte(tokenPlaintext))

	// Configurar la consulta SQL.
	query := `
	SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
	FROM users
	INNER JOIN tokens
	ON users.id = tokens.user_id
	WHERE tokens.hash = $1
	AND tokens.scope = $2
	AND tokens.expiry > $3`

	// Crear una slice que contenga los argumentos de la consulta. Observa cómo usamos el operador [:]
	// para obtener una slice que contiene el hash del token, en lugar de pasar el array (que no es
	// compatible con el controlador pq), y que pasamos el tiempo actual como el valor para comparar con
	// la caducidad del token.
	args := []interface{}{tokenHash[:], tokenScope, time.Now()}

	var user User
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// Ejecutar la consulta, escaneando los valores de retorno en una estructura User.
	// Si no se encuentra ningún registro coincidente, devolvemos un error ErrRecordNotFound.
	err := m.DB.QueryRowContext(ctx, query, args...).Scan(
		&user.ID,
		&user.CreatedAt,
		&user.Name,
		&user.Email,
		&user.Password.hash,
		&user.Activated,
		&user.Version,
	)
	if err != nil {
		switch {
		case errors.Is(err, sql.ErrNoRows):
			return nil, ErrRecordNotFound
		default:
			return nil, err
		}
	}

	// Devolver el usuario coincidente.
	return &user, nil
}

Ahora que está en su lugar, lo último que necesitamos hacer es agregar el punto de conexión PUT /v1/users/activated al archivo cmd/api/routes.go. De la siguiente manera:

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)
	router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler)
	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)
	router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)

	// Agregar la ruta para el punto de conexión PUT /v1/users/activated.
	router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)

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

Como un comentario adicional, debería explicar rápidamente que la razón por la que estamos utilizando PUT en lugar de POST para este punto de conexión es porque es idempotente.

Si un cliente envía la misma solicitud PUT /v1/users/activated varias veces, la primera tendrá éxito (si el token es válido) y luego cualquier solicitud posterior resultará en un error enviado al cliente (porque el token ha sido utilizado y eliminado de la base de datos). Pero lo importante es que nada en el estado de nuestra aplicación (es decir, la base de datos) cambia después de esa primera solicitud.

Básicamente, no hay efectos secundarios en el estado de la aplicación cuando el cliente envía la misma solicitud varias veces, lo que significa que el punto de conexión es idempotente y usar PUT es más apropiado que POST.

Muy bien, reiniciemos la API y luego probemos esto. Primero, intenta realizar algunas solicitudes al punto de conexión PUT /v1/users/activated con algunos tokens no válidos. Deberías recibir los mensajes de error correspondientes, algo así como:

$ curl -X PUT -d '{"token": "invalid"}' localhost:4000/v1/users/activated
{
	"error": {
		"token": "must be 26 bytes long"
	}
}

$ curl -X PUT -d '{"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}' localhost:4000/v1/users/activated
{
	"error": {
		"token": "invalid or expired activation token"
	}
}

Entonces, intenta hacer una solicitud utilizando un token de activación válido de uno de tus correos electrónicos (que estará en tu bandeja de entrada de Mailtrap si estás siguiendo los pasos). En mi caso, usaré el token P4B3URJZJ2NW5UPZC2OHN4H2NM para activar al usuario faith@example.com (que creamos en el capítulo anterior).

Deberías recibir una respuesta JSON con un campo “activated” que confirme que el usuario ha sido activado, algo así como:

$ curl -X PUT -d '{"token": "P4B3URJZJ2NW5UPZC2OHN4H2NM"}' localhost:4000/v1/users/activated
{
	"user": {
		"id": 7,
		"created_at": "2021-04-15T20:25:41+02:00",
		"name": "Faith Smith",
		"email": "faith@example.com",
		"activated": true
	}
}

Y si intentas repetir la solicitud nuevamente con el mismo token, ahora deberías obtener un error “invalid or expired activation token” debido a que hemos eliminado todos los tokens de activación para faith@example.com.

$ curl -X PUT -d '{"token": "P4B3URJZJ2NW5UPZC2OHN4H2NM"}' localhost:4000/v1/users/activated
{
	"error": {
		"token": "invalid or expired activation token"
	}
}

Importante: En producción, con tokens de activación para cuentas reales, debes asegurarte de que los tokens solo sean aceptados a través de una conexión HTTPS cifrada, no a través de HTTP regular como estamos utilizando aquí.

Finalmente, echemos un vistazo rápido a nuestra base de datos para ver el estado de nuestra tabla de usuarios.

$ 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, activated, version FROM users;
     email      | activated | version
-----------------+-----------+---------
alice@example.com | f         | 1
bob@example.com   | f         | 1
carol@example.com | f         | 1
dave@example.com  | f         | 1
edith@example.com | f         | 1
faith@example.com | t         | 2

A diferencia de todos los demás usuarios, podemos ver que faith@example.com ahora tiene el valor activated = true y el número de versión de su registro de usuario se ha incrementado a 2.

14.4 Informacion Adicional

14.4 Web application workflow

Si tu API es la parte trasera (backend) de un sitio web en lugar de ser un servicio completamente independiente, puedes ajustar el flujo de activación para que sea más simple e intuitivo para los usuarios, al mismo tiempo que sigue siendo seguro. Aquí hay dos opciones principales. La primera y más robusta opción es pedir al usuario que copie y pegue el token en un formulario en tu sitio web, que luego realizará la solicitud PUT /v1/users/activate por ellos utilizando algún código JavaScript. El correo electrónico de bienvenida para respaldar ese flujo de trabajo podría lucir algo así:

Hi,
Thanks for signing up for a Greenlight account. We&#39;re excited to have you on board!
For future reference, your user ID number is 123.
To activate your Greenlight account please visit h͟t͟t͟p͟s͟:͟/͟/͟e͟x͟a͟m͟p͟l͟e͟.͟c͟o͟m͟/͟u͟s͟e͟r͟s͟/͟a͟c͟t͟i͟v͟a͟t͟e
and
enter the following code:
--------------------------
Y3QMGX3PJ3WLRL2YRTQGQ6KRHU
--------------------------
Please note that this code will expire in 3 days and can only be used once.
Thanks,
The Greenlight Team

Este enfoque es fundamentalmente simple y seguro. Básicamente, tu sitio web simplemente proporciona un formulario que realiza la solicitud PUT en nombre del usuario, en lugar de que ellos tengan que hacerlo manualmente utilizando curl u otra herramienta.

Nota: Al crear el enlace en este correo electrónico, no confíes en la cabecera Host de r.Host para construir la URL, ya que eso sería vulnerable a un ataque de inyección de cabecera Host. El dominio de la URL debe estar codificado de forma estática o pasarse como un argumento de línea de comandos al iniciar la aplicación.

Alternativamente, si no deseas que el usuario copie y pegue un token, podrías pedirles que hagan clic en un enlace que contiene el token y los lleva a una página en tu sitio web. Similar a esto:

Hi,
Thanks for signing up for a Greenlight account. We&#39;re excited to have you on board!
For future reference, your user ID number is 123.
To activate your Greenlight account please click the following link:
h͟t͟t͟p͟s͟:͟/͟/͟e͟x͟a͟m͟p͟l͟e͟.͟c͟o͟m͟/͟u͟s͟e͟r͟s͟/͟a͟c͟t͟i͟v͟a͟t͟e͟?͟t͟o͟k͟e͟n͟=͟Y͟3͟Q͟M͟G͟X͟3͟P͟J͟3͟W͟L͟R͟L͟2͟Y͟R͟T͟Q͟G͟Q͟6͟K͟R͟H͟U
Please note that this link will expire in 3 days and can only be used once.
Thanks,
The Greenlight Team

Esta página debería mostrar un botón que diga algo como Confirma la activación de tu cuenta, y algún código JavaScript en la página web puede extraer el token de la URL y enviarlo a tu punto final de API PUT /v1/users/activate cuando el usuario hace clic en el botón. Si optas por esta segunda opción, también debes tomar medidas para evitar que el token se filtre en una cabecera de referencia (referrer header) si el usuario navega a un sitio diferente. Puedes usar la cabecera Referrer-Policy: Origin o la etiqueta HTML meta name=“referrer” content=“origin” para mitigar esto, aunque debes tener en cuenta que no es compatible con todos los navegadores web (actualmente, el soporte es de aproximadamente el 96%). En cualquier caso, independientemente de cómo se vea el correo electrónico y el flujo de trabajo en términos de la interfaz y la experiencia del usuario, el punto final de la API en el backend que hemos implementado es el mismo y no necesita cambiar.

14.4 Ataque de temporización de consulta SQL

Vale la pena señalar que la consulta SQL que estamos utilizando en UserModel.GetForToken() es teóricamente vulnerable a un ataque de temporización, ya que la evaluación de la condición tokens.hash = $1 en PostgreSQL no se realiza en tiempo constante.

SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1
--<-- This is vulnerable to a timing attack
AND tokens.scope = $2
AND tokens.expiry > $3

Aunque sería algo complicado de llevar a cabo, en teoría, un atacante podría emitir miles de solicitudes a nuestro punto final PUT /v1/users/activated y analizar pequeñas discrepancias en el tiempo de respuesta promedio para construir una imagen del valor hash del token de activación en la base de datos. Sin embargo, en nuestro caso, incluso si un ataque de temporización tuviera éxito, solo revelaría el valor del token hash de la base de datos, no el valor de texto sin formato que el usuario realmente necesita enviar para activar su cuenta. Por lo tanto, el atacante aún tendría que utilizar la fuerza bruta para encontrar una cadena de 26 caracteres que coincida con el hash SHA-256 que descubrieron mediante el ataque de temporización. Esto es increíblemente difícil de hacer y simplemente no es viable con la tecnología actual.

Post Relacionados