11. Usando peticiones de contexto

Hasta este momento nuestra logica de autenticacion consiste en chekear si hay un “authenticatedUserID” en nuestros datos de sesion, asi:

func (app *application) isAuthenticated(r *http.Request) bool {
  return app.sessionManager.Exists(r.Context(), "authenticatedUserID")
}

Podriamos hacer esta revision mas robusta , haciendo una consulta a la tabla users para estar serguros de que “authenticatedUserID” es real y valido.

Pero hay un ligero problema cuando hagamos esta revision adicional sobre la base de datos.

Nuesto helper isAuthenticated() puede ser llamado muchas veces en cada peticion. Actualmente lo usamos dos veces , una en el middleware requireAuthentication() y otra vez en el helper newTemplateData(), Entonces si hacemos las consultas dentro de isAuthenticated podriamos tener consultas duplicadas y demasiadas durante cada peticion. Lo cual no es eficiente.

Un mejor enfoque podria ser revisar esto a traves de un middleware que revise si la peticion actual es de un usuario autenticado o no y luego pasar esa informacion a los handlers subsecuentes en la cadena.

Como haremos esto? con request context.

En este post veremos :

  • Que es un request context, como usarlo y donde es apropiado usarlo.
  • Como usar el request context en la practica para pasar la informacion del usuario actual entre handlers.

11.1 Como funciona request context

Cada http.Request que nuestros middleware y handlers procesan tiene un objecto context.Context embebido en el, el cual podemos usar para alamcenar informacion durante el tiempo de vida de la peticion.

Como hemos insinuado , en una aplicacion web un caso de uso comun es pasar informacion entre las piezas de middleware y handlers.

En nuestro caso, queremos revisar si el usuario esta autenticado una vez en algun middleware, y si lo esta , entonces hacer esa informacion accesible para todos los demas middleware y handlers.

Vamos a empear con algo de teoria par explicar la sintaxis al trabajar con peticones de contexto. Luego en el siguiente post seremos mas concretos y demostraremos como usarlo en la practica para nuetra app.

11.1 La sintaxis request context

El codigo basico para agregar informacion a una request context es asi:

// Where r is a *http.Request...
ctx := r.Context()
ctx = context.WithValue(ctx, "isAuthenticated", true)
r = r.WithContext(ctx)

Veamos paso a paso cada linea.

  • Primero, usamos c.Context() para recibir el contexto existente de la peticion y le asignamos a la variable ctx.
  • Luego uasmos context.WithValue() para crear una nueva copia del contexto, conteniendo ahora una key isAuthenticated y el valor true.
  • Finalmente usamos el metodo r.WithContext() para crear una copia de la peticion conteniendo nuestro nuevo contexto.

Nota que actualmente no estamos actualizando el contexto para la peticion directamente. Lo que estamos haciendo es crear un nueva copia de http.Request con nuestro nuevo contexto en el.

Por claridad el codigo anterior lo hice mas verboso para poder
explicarlo, normalmente lo harias asi:

ctx = context.WithValue(r.Context(), "isAuthenticated", true)
r = r.WithContext(ctx)

Asi es como agregamod datos al request context, pero como podemos leerlo?

Lo importante que tenes que saber es que por detras, los valores en request context son almacenados con el tipo any . Esto significa que despues de recibirlos tenemos que hacer una asercion al tipo original .

Para recibir el valor usamos r.Context().Value() asi:

isAuthenticated, ok := r.Context().Value("isAuthenticated").(bool)
if !ok {
  return errors.New("could not convert value to bool")
}

11.1 Evitando colisiones en las keys

En el codigo anterior usamos el string isAuthenticated para almacenar y recibir los datos desde el request context . Pero no es recomentable porque corremos el riesgo que una libreria de terceros tambien la use, lo que podria provocar naming collision.

Para evitar esto, es buena practica crear nuestro propio tipo personalizado que podemos usar para nuestras context keys. Nuestro codigo seria mucho mejor asi:

// Declare a custom "contextKey" type for your context keys.
type contextKey string
// Create a constant with the type contextKey that we can use.
const isAuthenticatedContextKey = contextKey("isAuthenticated")
...

// Set the value in the request context, using our isAuthenticatedContextKey
// constant as the key.
ctx := r.Context()
ctx = context.WithValue(ctx, isAuthenticatedContextKey, true)
r = r.WithContext(ctx)

...

isAuthenticated, ok := r.Context().Value("isAuthenticatedContextKey").(bool)
if !ok {
  return errors.New("could not convert value to bool")
}

11.2 Request context para autenticacion/autorizacion

Comencemos yendo a internal/models/users.go y demos forma a UserModel.Exists() para que retorne true si el usuario con un ID especifico existe en nuestra tabla users y false si no es asi.

func (m *UserModel) Exists(id int) (bool, error) {
	var exists bool
	stmt := "SELECT EXISTS(SELECT true FROM users WHERE id = ?)"
	err := m.DB.QueryRow(stmt, id).Scan(&exists)
	return exists, err
}

Vamos a crear un nuevo archivo cmd/web/context.go donde definiremos un tipo personalizado contextKey y una variable isAuthenticatedContextKey asi tendremos una key unica que podemos usar para almacenar y recibir el estado de autenticacion desde una request context , sin el riesgo de colisiones de nombre.

$ touch cmd/web/context.go
package main

type contextKey string

const isAuthenticatedContextKey = contextKey("isAuthenticated")

Ahora vamos a crear un nuevo middleware llamado authenticate() el cual:

  • Recibe el user id de los datos de sesion.
  • Revisa la db para ver si el ID corresponde a un usuario valido usando el metodo UserModel.Exists().
  • Actualice el request context para incluir una key isAuthenticatedContextKey con el valor true.
func (app *application) authenticate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Retrieve the authenticatedUserID value from the session using the
		// GetInt() method. This will return the zero value for an int (0) if no
		// "authenticatedUserID" value is in the session -- in which case we
		// call the next handler in the chain as normal and return.
		id := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
		if id == 0 {
			next.ServeHTTP(w, r)
			return
		}
		// Otherwise, we check to see if a user with that ID exists in our
		// database.
		exists, err := app.users.Exists(id)
		if err != nil {
			app.serverError(w, r, err)
			return
		}
		// If a matching user is found, we know that the request is
		// coming from an authenticated user who exists in our database. We
		// create a new copy of the request (with an isAuthenticatedContextKey
		// value of true in the request context) and assign it to r.
		if exists {
			ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
			r = r.WithContext(ctx)
		}
		// Call the next handler in the chain.
		next.ServeHTTP(w, r)
	})
}

Lo importante:

  • Cuando no tenemos un usuario valido , pasamos el *http.Request original sin cambios al siguiente handler en la cadena.
  • Cuando tenemos un usuario autenticado , creamos una copia de la peticion con la key isAuthenticatedContextKey y el valor true y lo almacenados en el request context.Luego pasamos esta copia de *http.Request al siguiente handler en la cadena.

Vamos a modificar nuestro cmd/web/routes.go para incluir el middleware authenticate() en el middleware dynamic.

func (app *application) routes() http.Handler {

	router := httprouter.New()

	router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		app.notFound(w)
	})

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))

	// Add the authenticate() middleware to the chain.
	dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

	router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
	router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
	router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
	router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
	router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
	router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))

	// Because the 'protected' middleware chain appends to the 'dynamic' chain
	// the noSurf middleware will also be used on the three routes below too.
	protected := dynamic.Append(app.requireAuthentication)
	router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
	router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
	router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))

	standard := alice.New(app.recoverPanic, app.logRequest, sercureHeaders)

	return standard.Then(router)
}

Lo ultimo que tenemos que hacer es actualizar el helper isAuthenticated, para uq en vez de chekear los datos de sesion , chekee desde el request context para determinar si el usuario esta logeado o no.

  func (app *application) isAuthenticated(r *http.Request) bool {
-  	return app.sessionManager.Exists(r.Context(), "authenticatedUserID")
  
+  	isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
+  	if !ok {
+  		return false
+  	}
+ 	return isAuthenticated
  }

Aca lo importante es que si no hay un valor en el request context con la key isAuthenticatedContextKey o si el valor no es un bool, la asercion de tipo fallara y regresaremos false para que sea tratado como un usuario no autenticado.

Si reinicamos la app y estamos logeados y eliminamos al usuario con el que estamos, al refrescar la pagina la app se dara cuenta de que el usurio no existe y lo tratara como sin autenticar.

mysql> USE snippetbox;
mysql> DELETE FROM users WHERE email = 'bob@example.com';

11.2 Informacion adicional

11.2 Mal uso de request context

Es importante destacar que el contexto de la solicitud solo debe utilizarse para almacenar información relevante para la duración de una solicitud específica. La documentación de Go para context.Context advierte:

Utiliza los valores de contexto solo para datos relacionados con la solicitud que atraviesan procesos y APIs.

Eso significa que no debes usarlo para pasar dependencias que existen fuera de la duración de una solicitud, como registros (loggers), cachés de plantillas y tu pool de conexiones a la base de datos, a tus middlewares y controladores.

Por razones de seguridad de tipos y claridad de código, casi siempre es mejor poner estas dependencias a disposición de tus controladores de manera explícita, ya sea haciendo que tus controladores sean métodos en una estructura application (como hacemos en estos post) o pasándolas en un closure (como en este Gist).