12 Modelo de usuario y registro

En las próximas secciones de esta seccion, vamos a cambiar nuestro enfoque hacia los usuarios: registrarlos, activarlos, autenticarlos y restringir el acceso a nuestros puntos de conexión de API según los permisos que tengan.

Pero antes de poder realizar estas acciones, necesitamos establecer algunas bases. Específicamente, necesitamos:

  1. Crear una nueva tabla de users en PostgreSQL para almacenar nuestros datos de usuario.
  2. Crear un modelo de usuario (UserModel) que contenga el código para interactuar con nuestra tabla de usuarios, validar los datos de usuario y cifrar las contraseñas de usuario.
  3. Desarrollar un punto de conexión POST /v1/users que pueda utilizarse para registrar nuevos usuarios en nuestra aplicación.

12.1 Configurando la tabla de base de datos de los usuarios

Comencemos creando una nueva tabla de usuarios en nuestra base de datos. Si estás siguiendo el proceso, utiliza la herramienta de migración para generar un nuevo par de archivos de migración SQL:

$ migrate create -seq -ext=.sql -dir=./migrations create_users_table
/home/nahueldev23/Projects/greenlight/migrations/000004_create_users_table.up.sql
/home/nahueldev23/Projects/greenlight/migrations/000004_create_users_table.down.sql

Después, agrega las siguientes declaraciones SQL a los archivos ‘up’ y ‘down’, respectivamente:

CREATE TABLE IF NOT EXISTS users (
	id bigserial PRIMARY KEY,
	created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
	name text NOT NULL,
	email citext UNIQUE NOT NULL,
	password_hash bytea NOT NULL,
	activated bool NOT NULL,
	version integer NOT NULL DEFAULT 1
);
DROP TABLE IF EXISTS users;

Hay algunos aspectos interesantes sobre esta declaración CREATE TABLE que me gustaría explicar rápidamente:

  1. La columna de correo electrónico (email) tiene el tipo citext (texto insensible a mayúsculas y minúsculas). Este tipo almacena datos de texto exactamente como se ingresan, sin cambiar la mayúscula o minúscula de ninguna manera, pero las comparaciones con los datos siempre son insensibles a mayúsculas y minúsculas, incluidas las búsquedas en los índices asociados.

  2. También tenemos una restricción UNIQUE en la columna de correo electrónico (email). Combinado con el tipo citext, esto significa que no puede haber dos filas en la base de datos con el mismo valor de correo electrónico, incluso si tienen mayúsculas y minúsculas diferentes. Esto básicamente impone una regla comercial a nivel de base de datos de que no deben existir dos usuarios con la misma dirección de correo electrónico.

  3. La columna de contraseña (password_hash) tiene el tipo bytea (cadena binaria). En esta columna, almacenaremos un hash unidireccional de la contraseña del usuario generado usando bcrypt, no la contraseña en texto plano.

  4. La columna activated almacena un valor booleano para denotar si una cuenta de usuario está ‘activa’ o no. La estableceremos en false de manera predeterminada al crear un nuevo usuario y requeriremos que el usuario confirme su dirección de correo electrónico antes de establecerla en true.

  5. También hemos incluido una columna de número de versión (version), que incrementaremos cada vez que se actualice un registro de usuario. Esto nos permitirá utilizar el bloqueo optimista para evitar condiciones de carrera al actualizar registros de usuario, de la misma manera que hicimos con las películas anteriormente en el libro.

Ejecutemos las migraciones:

$ migrate -path=./migrations -database=$GREENLIGHT_DB_DSN up
4/u create_users_table (62.43511ms)

si la variable no te funciona podes poner esto directamente:

$ migrate -path=./migrations -database=postgres://greenlight:pa55word@localhost/greenlight up
4/u create_users_table (62.43511ms)

Luego, deberías poder conectarte a tu base de datos y verificar que se haya creado la nueva tabla de usuarios, como se esperaba.

$ 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=> \d users
Table "public.users"
 Column       |             Type              | Collation | Nullable |                 Default
------------------+-----------------------------+-----------+----------+-----------------------------------------
 id               | bigint                      |           | not null | nextval('users_id_seq'::regclass)
 created_at      | timestamp(0) with time zone |           | not null | now()
 name            | text                        |           | not null |
 email           | citext                      |           | not null |
 password_hash   | bytea                       |           | not null |
 activated       | boolean                     |           | not null | false
 version         | integer                     |           | not null | 1
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_email_key" UNIQUE CONSTRAINT, btree (email)

Es importante señalar aquí que la restricción UNIQUE en nuestra columna de correo electrónico se ha asignado automáticamente el nombre users_email_key. Esto será relevante en el próximo capítulo, cuando necesitemos manejar cualquier error causado por un usuario que se registra dos veces con la misma dirección de correo electrónico. La asignación automática de un nombre a esta restricción facilita la identificación y gestión de posibles conflictos en el futuro.

12.2 Configurando el User Model

Ahora que nuestra tabla de base de datos está configurada, vamos a actualizar nuestro paquete internal/data para contener una nueva estructura User (para representar los datos de un usuario individual) y crear un tipo UserModel (que utilizaremos para realizar varias consultas SQL contra nuestra tabla de usuarios). Si estás siguiendo estos pasos, adelante y crea un archivo internal/data/users.go para almacenar este nuevo código:

$ touch internal/data/users.go

Este comando crea un archivo llamado users.go en el directorio internal/data. A continuación, puedes abrir este archivo y agregar el código correspondiente para la estructura User y el tipo UserModel.

$ touch internal/data/users.go

Vamos a empezar definiendo la estructura User, junto con algunos métodos auxiliares para establecer y verificar la contraseña de un usuario.

Como mencionamos anteriormente, en este proyecto utilizaremos bcrypt para cifrar las contraseñas de los usuarios antes de almacenarlas en la base de datos. Lo primero que necesitamos hacer es instalar el paquete golang.org/x/crypto/bcrypt, que proporciona una implementación fácil de usar del algoritmo bcrypt en Go.

$ go get golang.org/x/crypto/bcrypt@latest
go: downloading golang.org/x/crypto v0.13.0
go get: added golang.org/x/crypto v0.13.0

En el archivo internal/data/users.go, procede a crear la estructura User y los métodos auxiliares de la siguiente manera:

package data

import (
	"errors"
	"time"

	"golang.org/x/crypto/bcrypt"
)

// Define a User struct to represent an individual user. Importantly, notice how we are
// using the json:"-" struct tag to prevent the Password and Version fields appearing in
// any output when we encode it to JSON. Also notice that the Password field uses the
// custom password type defined below.
type User struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"created_at"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	Password  password  `json:"-"`
	Activated bool      `json:"activated"`
	Version   int       `json:"-"`
}

// Create a custom password type which is a struct containing the plaintext and hashed
// versions of the password for a user. The plaintext field is a *pointer* to a string,
// so that we're able to distinguish between a plaintext password not being present in
// the struct at all, versus a plaintext password which is the empty string "".
type password struct {
	plaintext *string
	hash      []byte
}

// The Set() method calculates the bcrypt hash of a plaintext password, and stores both
// the hash and the plaintext versions in the struct.
func (p *password) Set(plaintextPassword string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12)
	if err != nil {
		return err
	}
	p.plaintext = &plaintextPassword
	p.hash = hash
	return nil
}

// The Matches() method checks whether the provided plaintext password matches the
// hashed password stored in the struct, returning true if it matches and false
// otherwise.
func (p *password) Matches(plaintextPassword string) (bool, error) {
	err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword))
	if err != nil {
		switch {
		case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
			return false, nil
		default:
			return false, err
		}
	}
	return true, nil
}

Explicamos anteriormente cómo funciona el paquete golang.org/x/crypto/bcrypt en “Let’s Go”, pero hagamos un breve resumen de los puntos clave:

  • La función bcrypt.GenerateFromPassword() genera un hash bcrypt de una contraseña utilizando un parámetro de costo específico (en el código anterior, usamos un costo de 12). Cuanto mayor sea el costo, más lento y más computacionalmente costoso será generar el hash. Existe un equilibrio aquí: queremos que el costo sea prohibitivamente caro para los atacantes, pero también lo suficientemente rápido como para no afectar la experiencia del usuario en nuestra API. Esta función devuelve una cadena de hash con el formato:
$2b$[cost]$[sal de 22 caracteres][hash de 31 caracteres]
  • La función bcrypt.CompareHashAndPassword() funciona volviendo a cifrar la contraseña proporcionada utilizando la misma sal y parámetro de costo que se encuentran en la cadena de hash con la que estamos comparando. El valor cifrado nuevamente se verifica contra la cadena de hash original utilizando la función subtle.ConstantTimeCompare(), que realiza una comparación en tiempo constante (para mitigar el riesgo de un ataque de temporización). Si no coinciden, devolverá un error bcrypt.ErrMismatchedHashAndPassword.

12.2 Agregando validaciones

Sigamos y creemos algunas comprobaciones de validación para nuestra estructura User. Específicamente, queremos:

  1. Verificar que el campo Name no sea una cadena vacía y que su longitud sea menor a 500 bytes.
  2. Verificar que el campo Email no sea una cadena vacía y que coincida con la expresión regular para direcciones de correo electrónico que agregamos en nuestro paquete de validación anteriormente en el libro.
  3. Si el campo Password.plaintext no es nulo, entonces verificar que el valor no sea una cadena vacía y que su longitud esté entre 8 y 72 bytes.
  4. Verificar que el campo Password.hash nunca sea nulo.

Nota: Al crear un hash bcrypt, la entrada se trunca a un máximo de 72 bytes. Entonces, si alguien utiliza una contraseña muy larga, significa que cualquier byte después de eso se ignoraría efectivamente al crear el hash. Para evitar cualquier confusión para los usuarios, simplemente impondremos una longitud máxima estricta de 72 bytes en la contraseña en nuestras comprobaciones de validación. Si no deseas imponer una longitud máxima, podrías pre-cifrar la contraseña en su lugar.

Además, vamos a querer utilizar las comprobaciones de validación del correo electrónico y la contraseña en texto plano de manera independiente más adelante en el libro. Por lo tanto, definiremos esas comprobaciones en algunas funciones independientes.

Adelante, actualiza el archivo internal/data/users.go de la siguiente manera:

package data

import (
	"errors"
	"time"

	"github.com/nahuelev23/greenlight/internal/validator"
	"golang.org/x/crypto/bcrypt"
)

// Define a User struct to represent an individual user. Importantly, notice how we are
// using the json:"-" struct tag to prevent the Password and Version fields appearing in
// any output when we encode it to JSON. Also notice that the Password field uses the
// custom password type defined below.
type User struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"created_at"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	Password  password  `json:"-"`
	Activated bool      `json:"activated"`
	Version   int       `json:"-"`
}

// Create a custom password type which is a struct containing the plaintext and hashed
// versions of the password for a user. The plaintext field is a *pointer* to a string,
// so that we're able to distinguish between a plaintext password not being present in
// the struct at all, versus a plaintext password which is the empty string "".
type password struct {
	plaintext *string
	hash      []byte
}

// The Set() method calculates the bcrypt hash of a plaintext password, and stores both
// the hash and the plaintext versions in the struct.
func (p *password) Set(plaintextPassword string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12)
	if err != nil {
		return err
	}
	p.plaintext = &plaintextPassword
	p.hash = hash
	return nil
}

// The Matches() method checks whether the provided plaintext password matches the
// hashed password stored in the struct, returning true if it matches and false
// otherwise.
func (p *password) Matches(plaintextPassword string) (bool, error) {
	err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword))
	if err != nil {
		switch {
		case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
			return false, nil
		default:
			return false, err
		}
	}
	return true, nil
}

func ValidateEmail(v *validator.Validator, email string) {
	v.Check(email != "", "email", "must be provided")
	v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address")
}
func ValidatePasswordPlaintext(v *validator.Validator, password string) {
	v.Check(password != "", "password", "must be provided")
	v.Check(len(password) >= 8, "password", "must be at least 8 bytes long")
	v.Check(len(password) <= 72, "password", "must not be more than 72 bytes long")
}
func ValidateUser(v *validator.Validator, user *User) {
	v.Check(user.Name != "", "name", "must be provided")
	v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long")
	// Call the standalone ValidateEmail() helper.
	ValidateEmail(v, user.Email)
	// If the plaintext password is not nil, call the standalone
	// ValidatePasswordPlaintext() helper.
	if user.Password.plaintext != nil {
		ValidatePasswordPlaintext(v, *user.Password.plaintext)
	}
	// If the password hash is ever nil, this will be due to a logic error in our
	// codebase (probably because we forgot to set a password for the user). It's a
	// useful sanity check to include here, but it's not a problem with the data
	// provided by the client. So rather than adding an error to the validation map we
	// raise a panic instead.
	if user.Password.hash == nil {
		panic("missing password hash for user")
	}
}

12.2 Creando UserModel

El siguiente paso en este proceso es configurar un tipo UserModel que aísle las interacciones con la base de datos en nuestra tabla de usuarios de PostgreSQL.

Seguiremos el mismo patrón que utilizamos para nuestro MovieModel e implementaremos los siguientes tres métodos:

  1. Insert() para crear un nuevo registro de usuario en la base de datos.
  2. GetByEmail() para recuperar los datos de un usuario con una dirección de correo electrónico específica.
  3. Update() para cambiar los datos de un usuario específico.

Abre nuevamente el archivo internal/data/users.go y agrega el siguiente código:

package data

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

	"github.com/nahuelev23/greenlight/internal/validator"
	"golang.org/x/crypto/bcrypt"
)

// Define a custom ErrDuplicateEmail error.
var (
	ErrDuplicateEmail = errors.New("duplicate email")
)
...

// Create a UserModel struct which wraps the connection pool.
type UserModel struct {
    DB *sql.DB
}

// Insert a new record in the database for the user. Note that the id, created_at, and
// version fields are all automatically generated by our database, so we use the
// RETURNING clause to read them into the User struct after the insert, in the same way
// that we did when creating a movie.
func (m UserModel) Insert(user *User) error {
    query := `
        INSERT INTO users (name, email, password_hash, activated)
        VALUES ($1, $2, $3, $4)
        RETURNING id, created_at, version`
    args := []interface{}{user.Name, user.Email, user.Password.Hash, user.Activated}
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    // If the table already contains a record with this email address, then when we try
    // to perform the insert there will be a violation of the UNIQUE "users_email_key"
    // constraint that we set up in the previous chapter. We check for this error
    // specifically and return a custom ErrDuplicateEmail error instead.
    err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version)
    if err != nil {
        switch {
        case strings.Contains(err.Error(), `duplicate key value violates unique constraint "users_email_key"`):
            return ErrDuplicateEmail
        default:
            return err
        }
    }
    return nil
}

// Retrieve the User details from the database based on the user's email address.
// Because we have a UNIQUE constraint on the email column, this SQL query will only
// return one record (or none at all, in which case we return an ErrRecordNotFound error).
func (m UserModel) GetByEmail(email string) (*User, error) {
    query := `
        SELECT id, created_at, name, email, password_hash, activated, version
        FROM users
        WHERE email = $1`
    var user User
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    err := m.DB.QueryRowContext(ctx, query, email).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
        }
    }
    return &user, nil
}

// Update the details for a specific user. Notice that we check against the version
// field to help prevent any race conditions during the request cycle, just like we did
// when updating a movie. And we also check for a violation of the "users_email_key"
// constraint when performing the update, just like we did when inserting the user
// record originally.
func (m UserModel) Update(user *User) error {
    query := `
        UPDATE users
        SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1
        WHERE id = $5 AND version = $6
        RETURNING version`
    args := []interface{}{
        user.Name,
        user.Email,
        user.Password.Hash,
        user.Activated,
        user.ID,
        user.Version,
    }
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version)
    if err != nil {
        switch {
        case strings.Contains(err.Error(), `duplicate key value violates unique constraint "users_email_key"`):
            return ErrDuplicateEmail
        case errors.Is(err, sql.ErrNoRows):
            return ErrEditConflict
        default:
            return err
        }
    }
    return nil
}

Con suerte, esto parece bastante directo; estamos utilizando los mismos patrones de código que usamos para las operaciones CRUD en nuestra tabla de películas anteriormente en el libro.

La única diferencia es que, en algunos de los métodos, estamos comprobando específicamente cualquier error debido a una violación de nuestra restricción única users_email_key. Como veremos en el próximo capítulo, al tratar esto como un caso especial, podremos responder a los clientes con un mensaje que indique “esta dirección de correo electrónico ya está en uso”, en lugar de enviarles una respuesta de error interno del servidor 500 como lo haríamos normalmente.

Para finalizar todo esto, lo último que debemos hacer es actualizar nuestro archivo internal/data/models.go para incluir el nuevo UserModel en nuestra estructura Models principal. Así:

package data

import (
	"database/sql"
	"errors"
)

var (
	ErrRecordNotFound = errors.New("record not found")
	ErrEditConflict   = errors.New("edit conflict")
)


type Models struct {
	Movies MovieModel
	Users  UserModel // Add a new Users field.
}


func NewModels(db *sql.DB) Models {
	return Models{
		Movies: MovieModel{DB: db},
		Users:  UserModel{DB: db}, // Initialize a new UserModel instance.
	}
}

12.3 Registrando un usuario

Ahora que hemos establecido las bases, comencemos a ponerlo en práctica creando un nuevo punto de conexión API para gestionar el proceso de registro (o registro) de un nuevo usuario.

MethodURL PatternHandlerAction
GET/v1/healthcheckhealthcheckHandlerShow application information
GET/v1/movieslistMoviesHandlerShow the details of all movies
POST/v1/moviescreateMovieHandlerCreate a new movie
GET/v1/movies/:idshowMovieHandlerShow the details of a specific movie
PATCH/v1/movies/:idupdateMovieHandlerUpdate the details of a specific movie
DELETE/v1/movies/:iddeleteMovieHandlerDelete a specific movie
POST/v1/users/registerregisterUserHandlerRegister a new user

Cuando un cliente llama a este nuevo punto de conexión POST /v1/users, esperamos que proporcionen los siguientes detalles para el nuevo usuario en el cuerpo de la solicitud JSON. Similar a esto:

{
	"name": "Alice Smith",
	"email": "alice@example.com",
	"password": "pa55word"
}

Cuando recibimos esto, el registerUserHandler debería crear una nueva estructura User que contenga estos detalles, validarla con la ayuda de ValidateUser(), y luego pasarla al método UserModel.Insert() para crear un nuevo registro en la base de datos.

De hecho, ya hemos escrito la mayor parte del código que necesitamos para registerUserHandler; ahora es solo cuestión de reunirlo todo en el orden correcto. Si estás siguiendo el ejemplo, adelante y crea un nuevo archivo cmd/api/users.go:

$ touch cmd/api/users.go

Luego agrega el nuevo método registerUserHandler que contiene el siguiente código:

package main

import (
	"errors"
	"net/http"

	"github.com/nahuelev23/greenlight/internal/data"
	"github.com/nahuelev23/greenlight/internal/validator"
)

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
	// Create an anonymous struct to hold the expected data from the request body.
	var input struct {
		Name string  `json:"name"`
		Email string `json:"email"`
		Password    string `json:"password"`
	}
	// Parse the request body into the anonymous struct.
	err := app.readJSON(w, r, &input)
	if err != nil {
		app.badRequestResponse(w, r, err)
		return
	}
	// Copy the data from the request body into a new User struct. Notice also that we
	// set the Activated field to false, which isn't strictly necessary because the
	// Activated field will have the zero-value of false by default. But setting this
	// explicitly helps to make our intentions clear to anyone reading the code.
	user := &data.User{
		Name:      input.Name,
		Email:     input.Email,
		Activated: false,
	}
	// Use the Password.Set() method to generate and store the hashed and plaintext
	// passwords.
	err = user.Password.Set(input.Password)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}
	v := validator.New()
	// Validate the user struct and return the error messages to the client if any of
	// the checks fail.
	if data.ValidateUser(v, user); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}

	// Insert the user data into the database.
	err = app.models.Users.Insert(user)
	if err != nil {
		switch {
		// If we get a ErrDuplicateEmail error, use the v.AddError() method to manually
		// add a message to the validator instance, and then call our
		// failedValidationResponse() helper.
		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
	}
	// Write a JSON response containing the user data along with a 201 Created status
	// code.
	err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Antes de probar esto, también necesitamos agregar el nuevo punto de conexión POST /v1/users a nuestro archivo cmd/api/routes.go. Así:

package main

import (
	"net/http"

	"github.com/julienschmidt/httprouter"
)

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)

	// Add the route for the POST /v1/users endpoint.
	router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)

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

Una vez hecho eso, asegúrate de que todos los archivos estén guardados e inicia la API. Luego, realiza una solicitud al punto de conexión POST /v1/users para registrar un nuevo usuario con la dirección de correo electrónico alice@example.com. Deberías recibir una respuesta 201 Created mostrando los detalles del usuario, similar a esto:

$ BODY='{"name": "Alice Smith", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 201 Created
Content-Type: application/json
Date: Mon, 15 Mar 2021 14:42:58 GMT
Content-Length: 152
{
  "user": {
    "id": 1,
    "created_at": "2021-03-15T15:42:58+01:00",
    "name": "Alice Smith",
    "email": "alice@example.com",
    "activated": false
  }
}

Sugerencia: Si estás siguiendo el ejemplo, recuerda la contraseña que usaste en la solicitud anterior, ¡la necesitarás más adelante!

¡Eso se ve bien! Podemos ver por el código de estado que el registro del usuario se ha creado correctamente y, en la respuesta JSON, podemos ver la información generada por el sistema para el nuevo usuario, incluido el ID del usuario y el estado de activación.

Si echas un vistazo a tu base de datos PostgreSQL, también deberías ver el nuevo registro en la tabla de usuarios. Algo así:

$ 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 users;
 id |         created_at         |    name    |       email       |         password_hash         | activated | version
----+----------------------------+------------+-------------------+--------------------------------+-----------+---------
  1 | 2021-04-11 14:29:45+02 | Alice Smith | alice@example.com | \x24326124313224526157784d67356d... | f         | 1
(1 row)

Nota: La herramienta psql siempre muestra los valores bytea como una cadena codificada en hexadecimal. Entonces, el campo password_hash en la salida anterior muestra una codificación en hexadecimal del hash bcrypt. Si lo deseas, puedes ejecutar la siguiente consulta para agregar también la versión de cadena regular a la tabla: SELECT *, encode(password_hash, 'escape') FROM users.

Vamos a intentar realizar otra solicitud a nuestra API, pero con algunos detalles de usuario no válidos. En esta ocasión, nuestras comprobaciones de validación entrarán en juego y el cliente debería recibir los mensajes de error pertinentes. Por ejemplo:

$ BODY='{"name": "", "email": "bob@invalid.", "password": "pass"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
  "error": {
    "email": "must be a valid email address",
    "name": "must be provided",
    "password": "must be at least 8 bytes long"
  }
}

Finalmente, intenta registrar una segunda cuenta para alice@example.com. Esta vez deberías obtener un error de validación que contenga un mensaje de “ya existe un usuario con esta dirección de correo electrónico”, así:

$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 422 Unprocessable Entity
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 30 Dec 2020 14:22:06 GMT
Content-Length: 78
{
  "error": {
    "email": "a user with this email address already exists"
  }
}

Si lo deseas, también puedes intentar enviar algunas solicitudes utilizando mayúsculas alternativas para alice@example.com, como ALICE@example.com o Alice@Example.com. Debido a que la columna de correo electrónico en nuestra base de datos tiene el tipo citext, estas versiones alternativas también se identificarán correctamente como duplicados.

12.2 Informacion adicional

12.2 Email case-sensivity

Hablemos brevemente sobre la sensibilidad a mayúsculas y minúsculas de las direcciones de correo electrónico con un poco más de detalle.

Gracias a las especificaciones en RFC 2821, la parte de dominio de una dirección de correo electrónico (usuario@dominio) no distingue entre mayúsculas y minúsculas. Esto significa que podemos estar seguros de que la persona detrás de alice@example.com es la misma que alice@EXAMPLE.COM.

La parte de nombre de usuario de una dirección de correo electrónico puede o no ser sensible a mayúsculas y minúsculas, dependiendo del proveedor de correo electrónico. Casi todos los principales proveedores de correo electrónico tratan el nombre de usuario como insensible a mayúsculas y minúsculas, pero no hay garantía absoluta. Todo lo que podemos decir aquí es que la persona detrás de la dirección alice@example.com probablemente (pero no definitivamente) sea la misma que ALICE@example.com.

Entonces, ¿qué significa esto para nuestra aplicación?

Desde un punto de vista de seguridad, siempre deberíamos almacenar la dirección de correo electrónico con la capitalización exacta proporcionada por el usuario durante el registro, y deberíamos enviarles correos electrónicos utilizando esa capitalización exacta únicamente. Si no lo hacemos, existe el riesgo de que los correos electrónicos se entreguen a la persona equivocada en la vida real. Es especialmente importante tener esto en cuenta en cualquier flujo de trabajo que utilice el correo electrónico con fines de autenticación, como un flujo de trabajo de restablecimiento de contraseña.

Sin embargo, dado que alice@example.com y ALICE@example.com probablemente sean la misma persona, generalmente deberíamos tratar las direcciones de correo electrónico como insensibles a mayúsculas y minúsculas para fines de comparación.

En nuestro flujo de trabajo de registro, utilizar una comparación insensible a mayúsculas y minúsculas evita que los usuarios registren cuentas múltiples accidentalmente (o intencionalmente) solo cambiando las mayúsculas. Y desde el punto de vista de la experiencia del usuario, en flujos de trabajo como inicio de sesión, activación o restablecimiento de contraseña, es más tolerante si no requerimos que envíen su solicitud con la misma capitalización exacta que utilizaron al registrarse.

12.2 Enumeracion de usuarios

Es importante tener en cuenta que nuestro punto de conexión de registro es vulnerable a la enumeración de usuarios. Por ejemplo, si un atacante quiere saber si alice@example.com tiene una cuenta con nosotros, todo lo que necesita hacer es enviar una solicitud como esta:

$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
  "error": {
    "email": "a user with this email address already exists"
  }
}

Y tienen la respuesta ahí mismo. Estamos diciendo explícitamente al atacante que alice@example.com ya es un usuario.

Entonces, ¿cuáles son los riesgos de filtrar esta información?

El primer riesgo, más obvio, se relaciona con la privacidad del usuario. Para servicios que son sensibles o confidenciales, probablemente no quieras que sea obvio quién tiene una cuenta. El segundo riesgo es que facilita que un atacante comprometa la cuenta de un usuario. Una vez que conocen la dirección de correo electrónico de un usuario, pueden potencialmente:

  • Dirigirse al usuario con ingeniería social u otro tipo de ataque personalizado.
  • Buscar la dirección de correo electrónico en tablas de contraseñas filtradas y probar esas mismas contraseñas en nuestro servicio.

Prevenir ataques de enumeración generalmente requiere dos cosas:

  1. Asegurarse de que la respuesta enviada al cliente siempre sea exactamente la misma, independientemente de si un usuario existe o no. En general, esto significa cambiar la redacción de su respuesta para que sea ambigua e informar al usuario de cualquier problema en un canal lateral (como enviarles un correo electrónico para informarles que ya tienen una cuenta).
  2. Asegurarse de que el tiempo que lleva enviar la respuesta sea siempre el mismo, independientemente de si un usuario existe o no. En Go, esto generalmente significa externalizar el trabajo a un goroutine en segundo plano.

Desafortunadamente, estas mitigaciones tienden a aumentar la complejidad de tu aplicación y agregar fricción y oscuridad a tus flujos de trabajo. Para todos tus usuarios habituales que no son atacantes, son una desventaja desde el punto de vista de la experiencia del usuario (UX). Debes preguntarte: ¿vale la pena el intercambio?

Hay algunas cosas que debes tener en cuenta al responder a esta pregunta. ¿Qué tan importante es la privacidad del usuario en tu aplicación? ¿Qué tan atractiva (de alto valor) es una cuenta comprometida para un atacante? ¿Qué tan importante es reducir la fricción en tus flujos de trabajo de usuario? Las respuestas a esas preguntas variarán de proyecto a proyecto y ayudarán a formar la base de tu decisión.

Vale la pena señalar que muchos servicios de renombre, incluidos Twitter, GitHub y Amazon, no previenen la enumeración de usuarios (al menos no en sus páginas de registro). No estoy sugiriendo que esto esté bien; solo que esas empresas han decidido que la fricción adicional para el usuario es peor que los riesgos de privacidad y seguridad en su caso específico.

Post Relacionados