10. Autenticacion de usuarios

En este post vamos a agregar la funcionalidad de autenticacion de usuarios, para que solo los usuarios registrados puedan crear nuevos snippets. Quienes no este autenticados todavia podran leer los snippets y se podran crear una cuenta.

El fujo de trabajo sera el siguiente:

  1. Un usuario se registrara visitando /user/signup e ingresara su nombre,email y password.Estos datos se guardaran en la tabla users de la DB. (la crearemos en unos momentos).
  2. El usuario se logeara visitando /user/login e ingresando su email y su password.
  3. Luego veremos si en la DB hay un usuario que haga a match con ese usuario y ese password en la tabla users , si lo hay entences el usuario fue autenticado correctamente y agregaremos el id del usuario a los datos de sesion, usando la key authenticatedUserID.
  4. Cuando recibamos las peticiones siguientes, reviaremos los datos de sesion del usuario buscando la key authenticatedUserID. Si estiste sabemos que el usuario ya esta logeado.Podemos seguir comprobando esto hasta que la sesion expire, en ese momento el usuario debera logearse otravez. Si no tenemos la key authenticatedUserID en la sesion, sabemos que el usuario no esta logeado.

Aprenderemos:

  • Como implementar de manera basica un signup,login y logout para los usuarios.
  • Un enfoque seguro para encriptar y almacenar los password de los usurios en nuestra DB.
  • Un sencillo y solido enfoque para verificar si el usuario esta logeado usando middleware y session.
  • Como evitar cross-site request forgery (CSRF) attacks.

10.1 Configuracion de las rutas

Vamos a comezar agregando algunas reutas as nuestra app para que se vea algo asi:

MethodPatternHandlerAction
GET/homeDisplay the home page
GET/snippet/view/:idsnippetViewDisplay a specific snippet
GET/snippet/createsnippetCreateDisplay a HTML form for creating a new
snippet
POST/snippet/createsnippetCreatePostCreate a new snippet
GET/user/signupuserSignupDisplay a HTML form for signing up a new user
POST/user/signupuserSignupPostCreate a new user
GET/user/loginuserLoginDisplay a HTML form for logging in a user
POST/user/loginuserLoginPostAuthenticate and login the user
POST/user/logoutuserLogoutPostLogout the user
GET/static/*filepathhttp.FileServerServe a specific static file

Vamos a handlers.go u agreguemos los cinco nuevos handlers.

package main
...
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Display a HTML form for signing up a new user...")
}
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Create a new user...")
}
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Display a HTML form for logging in a user...")
}
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Authenticate and login the user...")
}
func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Logout the user...")
}

Ahora creemos las rutas correspondientes en routes.go.

package main

import (
	"net/http"

	"github.com/julienschmidt/httprouter"
	"github.com/justinas/alice" // New import
)

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

	dynamic := alice.New(app.sessionManager.LoadAndSave)

	router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
	router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
	router.Handler(http.MethodGet, "/snippet/create", dynamic.ThenFunc(app.snippetCreate))
	router.Handler(http.MethodPost, "/snippet/create", dynamic.ThenFunc(app.snippetCreatePost))

	// Add the five new routes, all of which use our 'dynamic' middleware chain.
	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))
	router.Handler(http.MethodPost, "/user/logout", dynamic.ThenFunc(app.userLogoutPost))

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

	return standard.Then(router)
}

Finalmente, tambien tenemos que actualizar nav.html para incluir las navegaciones a las nuevas paginas.

{{define "nav"}}
<nav>
  <div>
    <a href="/">Home</a>
    <a href="/snippet/create">Create snippet</a>
  </div>
  <div>
    <a href="/user/signup">Signup</a>
    <a href="/user/login">Login</a>
    <form action="/user/logout" method="POST">
      <button>Logout</button>
    </form>
  </div>
</nav>
{{end}}

Reinicia el servidor para ver que todo este en su lugar.

10.2 Creando el modelo users

Vamso a crear nuestra nueva tabla users en nuestra DB .

Empecemos yendo a la terminal y nos conectamos a MySQL como usuario root y ejecutamos la siguiente declaracion para crear la tabla users

sudo mysql

USE snippetbox;
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
hashed_password CHAR(60) NOT NULL,
created DATETIME NOT NULL
);
ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);

Expliquemos un par de cosas:

  • El campo id es autoincremental de tipo int y es la clave primaria de la tabla, esto significa que estamos garantizando que siempre sera unico y positivo , (1,2,3,4,5…x).
  • El campo hashed_password es un char(60). Esto es asi porque guardaremos un bcrypt hash como password y no el password de manera transparente , y siempre que hagamos un hash el largo sera de 60 caracteres de largo.
  • Tambien agregamos la CONSTRAINT UNIQUE al campo email y lo llamamos _users_uc_email. Esta CONSTRAINT nos asegura que no tendremos dos usurios con el mismo email, si intentamos insertar un email repetido MySQL arrojara ERROR 1062: ER_DUP_ENTRY

10.2 Construyendo el modelo en Go

Vamos a configurar unos modelos para que sea facil trabajar con la tabla users. Seguiremos usando el mismo patron que en anteriores post.

Primero abrimos internal/models/errors.go y definimos un par de nuevos erroes.

package models

import "errors"

var (
	ErrNoRecord = errors.New("models: no matching record found")

	// Add a new ErrInvalidCredentials error. We'll use this later if a user
	// tries to login with an incorrect email address or password.
	ErrInvalidCredentials = errors.New("models: invalid credentials")
	// Add a new ErrDuplicateEmail error. We'll use this later if a user
	// tries to signup with an email address that's already in use.
	ErrDuplicateEmail = errors.New("models: duplicate email")
)

Luego creamos un nuevo archivo internal/models/users.go

$ touch internal/models/users.go

Y definimos una nueva estructura User para mantener los datos para un usuario especifico y una estructura UserModel la cual vincularemos a algunos metodos para interactuar con la DB asi :

package models

import (
	"database/sql"
	"time"
)

// Define a new User struct. Notice how the field names and types align
// with the columns in the database "users" table?
type User struct {
	ID             int
	Name           string
	Email          string
	HashedPassword []byte
	Created        time.Time
}

// Define a new UserModel struct which wraps a database connection pool.
type UserModel struct {
	DB *sql.DB
}

// We'll use the Insert method to add a new record to the "users" table.
func (m *UserModel) Insert(name, email, password string) error {
	return nil
}

// We'll use the Authenticate method to verify whether a user exists with
// the provided email address and password. This will return the relevant
// user ID if they do.
func (m *UserModel) Authenticate(email, password string) (int, error) {
	return 0, nil
}

// We'll use the Exists method to check if a user exists with a specific ID.
func (m *UserModel) Exists(id int) (bool, error) {
	return false, nil
}

El paso final es agregar un nuevo campo a la estructura application para que podamos hacerlo visible en nuestros handlers. Vamos a main.go.

package main

  ...
  type application struct {
  	logger         *slog.Logger
  	snippets       models.SnippetModel
+  	users          models.UserModel
  	templateCache  map[string]*template.Template
  	formDecoder    *form.Decoder
  	sessionManager *scs.SessionManager
  }
  
  func main() {
  	addr := flag.String("addr", ":4000", "HTTP network address")
  
  	dsn := flag.String("dsn", "web:t0m0c4f3@/snippetbox?parseTime=true", "HTTP network address")
  
  	flag.Parse()
  
  	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
  
  	db, err := openDB(*dsn)
  	if err != nil {
  		logger.Error(err.Error())
  		os.Exit(1)
  	}
  
  	defer db.Close()
  	templateCache, err := newTemplateCache()
  	if err != nil {
  		logger.Error(err.Error())
  		os.Exit(1)
  	}
  
  	formDecoder := form.NewDecoder()
  
  	sessionManager := scs.New()
  	sessionManager.Store = mysqlstore.New(db)
  	sessionManager.Lifetime = 12 * time.Hour
  	sessionManager.Cookie.Secure = true
  
  	app := &application{
  		logger:         logger,
  		snippets:       &models.SnippetModel{DB: db},
+ 		users:          &models.UserModel{DB: db},
  		templateCache:  templateCache,
  		formDecoder:    formDecoder,
  		sessionManager: sessionManager,
  	}
  
  	tlsConfig := &tls.Config{
  		CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
  	}
  	srv := &http.Server{
  		Addr:      *addr,
  		Handler:   app.routes(),
  		ErrorLog:  slog.NewLogLogger(logger.Handler(), slog.LevelError),
  		TLSConfig: tlsConfig,
  		IdleTimeout:  time.Minute,
  		ReadTimeout:  5 * time.Second,
  		WriteTimeout: 10 * time.Second,
  	}
  
  	logger.Info("Starting server", "addr", *addr)
  
  	err = srv.ListenAndServeTLS("tls/cert.pem", "tls/key.pem")
  	logger.Error(err.Error())
  	os.Exit(1)
  }
  
  ...

Reinicia el servidor en este punto y asegurate de que todo sigue funcionando.

10.3 Registro de usuario y encriptacion de password

Antes de que podamos logear a los usuarios en nuestr app, necesitamos una manera de que registren una cuenta.

Lo primero es pintar una pantalla donde este el formulario, creemos ui/html/pages/signup.html

$ touch ui/html/pages/signup.html
{{define "title"}}Signup{{end}} {{define "main"}}
<form action="/user/signup" method="POST" novalidate>
  <div>
    <label>Name:</label>
    {{with .Form.FieldErrors.name}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="text" name="name" value="{{.Form.Name}}" />
  </div>
  <div>
    <label>Email:</label>
    {{with .Form.FieldErrors.email}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="email" name="email" value="{{.Form.Email}}" />
  </div>
  <div>
    <label>Password:</label>
    {{with .Form.FieldErrors.password}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="password" name="password" />
  </div>
  <div>
    <input type="submit" value="Signup" />
  </div>
</form>
{{end}}

Nota que si el formulario regresa con errores no volvemos a rellenar el campo password, esto es asi para no correr el riesgo de que se intente leer el password en texto plano.

Ahora vamos a cmd/web/handlers.go para incluir una nueva estructura userSignupForm la cual representa el contendor de los datos del formulario y lo usamos en el handler userSignup.

package main
...
// Create a new userSignupForm struct.
type userSignupForm struct {
	Name                string `form:"name"`
	Email               string `form:"email"`
	Password            string `form:"password"`
	validator.Validator `form:"-"`
}

func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
	data := app.newTemplateData(r)
	data.Form = userSignupForm{}
	app.render(w, r, http.StatusOK, "signup.html", data)
}
...

Reiniciamos el servidor y vamos a https://localhost:4000/user/signup deberiamos ver la web correctamente.

10.3 Validaciones en los inputs del usuario

Cuando el formulario sea enviado, los datos iran acia el handler userSignupPost que creamos antes.

La primera tarea de este handler sera validar los datos, para que sean correctos antes de agregarlos a la base de datos. Especialmente queremos hacer cuatro cosas.

  1. Revisar que el name, email y password no esten vacios.
  2. Revisar el formato del email.
  3. Asegurarnos que el password tenga al menos 8 caracteres.
  4. Asergurarnos que el email no este ya registrado.

Vamos a crubrir las primeras tres validaciones dentro de internal/validator/validator.go y creando dos nuevos helpers MinChars() y Matches() con la ayuda de expresiones regulares veremos si esta formado correctamente el campo email.

package validator

... 

import (
"regexp" // New import
"slices"
"strings"
"unicode/utf8"
)
...

// Use the regexp.MustCompile() function to parse a regular expression pattern
// for sanity checking the format of an email address. This returns a pointer to
// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing
// this pattern once at startup and storing the compiled *regexp.Regexp in a
// variable is more performant than re-parsing the pattern each time we need it.
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")



// MinChars() returns true if a value contains at least n characters.
func MinChars(value string, n int) bool {
	return utf8.RuneCountInString(value) >= n
}

// Matches() returns true if a value matches a provided compiled regular
// expression pattern.
func Matches(value string, rx *regexp.Regexp) bool {
	return rx.MatchString(value)
}

Tengo unas cosas que decir sobre EmailRX

  • El patron que usamos es el que actualmente recomienda la W3C and Web Hypertext Application Technology Working Group para validar una direccion de email. Para mas informacion sobre este patron, mira aca
  • Como el patron regexp EmailRX esta escrito como un string literal , necesitamos double-escape los caracteres especiales con \ para que funcione correctamente.No podemos usar una cadena cruda porque el patron contiene dentro estas comillas en si mismas.Si no estas familiarizado con los string litreals podes ver esta seccion

Vamos a handlers.go y agreguemos algo de codigo para procesar la validacion de los datos que no envien.

...
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
	// Declare an zero-valued instance of our userSignupForm struct.
	var form userSignupForm
	// Parse the form data into the userSignupForm struct.
	err := app.decodePostForm(r, &form)
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}
	// Validate the form contents using our helper functions.
	form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
	form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
	form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
	form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
	form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")
	// If there are any errors, redisplay the signup form along with a 422
	// status code.
	if !form.Valid() {
		data := app.newTemplateData(r)
		data.Form = form
		app.render(w, r, http.StatusUnprocessableEntity, "signup.html", data)
		return
	}
	// Otherwise send the placeholder response (for now!).
	fmt.Fprintln(w, "Create a new user...")
}
...

Reinicia el servidor e intenta enviar el formulario con datos incorrectos, deberias ver los input en rojo con los errores.

Solo nos queda validar que el email no este ya en uso.

Como ya tenemos la contraint UNIQUE en el campo email de nuestra table users ya tenemos la garantia de que no se agregar un segundo email repetido, el tema aca es como le decimos al usuario que ese email ya esta en uso. Lo veremos un poco mas adelante.

10.3 Una breve introduccion a bcrypt

Si nuestra DB es alguna vez vulnerada es muy importante no tener los password en texto plano .

Es buena practica (o mejor dicho esencial) almacenar los password de manera hasheada, podes derivar esta tarea a una libreria de terceros como Argon2, scrypt o bcrypt, Go tiene implementaciones de los tres algoritmos en el paquete golang.org/x/crypto

Usaremos bcrypt.

$ 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

Hay dos funciones que usaremos bcrypt.GenerateFromPassword() que nos sirve para hashear un texto plano asi :

hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)

Esto devolvera un hash de 60 caracteres:

$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG

El segundo parametro que pasamos (12) es el costo, el cual representa un numero entre 4 y 31. Por ejemplo usamos 12 de costo lo que significa que hara una iteracion para generar el password de 4096 (2^12)

Cuanto mas alto es este numero mas dificil sera crackearlo ( lo cual es good). Pero un costo alto significa que nuestra app tendra mas trabajo al momento de crear el hash e incrementara los recursos que usa nuestra app y la velocidad que experimenta el usuario.

Por otro lado podemos comparar ese hash con un texto plano para ver si coinciden con bcrypt.CompareHashAndPassword().

hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG")
err := bcrypt.CompareHashAndPassword(hash, []byte("my plain text password"))

bcrypt.CompareHashAndPassword() retornara nil si hace match y error si no hace match.

Almacenando al usuario en la DB

En esta etapa vamos a actualizar el metodo UserModel.Insert() para que cree un nuevo registro en la tabla users.

Lo primero queremos guardar el password hasheado y lo segundo queremos manejar el error potencial causado por la duplicidad del email.

Todos los errores retornados de MySQL tienen un codigo en particular el cual podemos usar para conocer la causa del error.En el caso de un email duplicado el error es el 1062 (ER_DUP_ENTRY).

Abramos internal/models/users.go y actualicemos lo siguiente.

// We'll use the Insert method to add a new record to the "users" table.
func (m *UserModel) Insert(name, email, password string) error {
	// Create a bcrypt hash of the plain-text password.
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
	if err != nil {
		return err
	}
	stmt := `INSERT INTO users (name, email, hashed_password, created)
	VALUES(?, ?, ?, UTC_TIMESTAMP())`
	// Use the Exec() method to insert the user details and hashed password
	// into the users table.
	_, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
	if err != nil {
		// If this returns an error, we use the errors.As() function to check
		// whether the error has the type *mysql.MySQLError. If it does, the
		// error will be assigned to the mySQLError variable. We can then check
		// whether or not the error relates to our users_uc_email key by
		// checking if the error code equals 1062 and the contents of the error
		// message string. If it does, we return an ErrDuplicateEmail error.
		var mySQLError *mysql.MySQLError
		if errors.As(err, &mySQLError) {
			if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
				return ErrDuplicateEmail
			}
		}
		return err
	}
	return nil
}

Podemos terminarlo del todo actualizando userSignup

package main

...

func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
	// Declare an zero-valued instance of our userSignupForm struct.
	var form userSignupForm
	// Parse the form data into the userSignupForm struct.
	err := app.decodePostForm(r, &form)
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
	form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
	form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
	form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
	form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")

	if !form.Valid() {
		data := app.newTemplateData(r)
		data.Form = form
		app.render(w, r, http.StatusUnprocessableEntity, "signup.html", data)
		return
	}

	// Try to create a new user record in the database. If the email already
	// exists then add an error message to the form and re-display it.
	err = app.users.Insert(form.Name, form.Email, form.Password)
	if err != nil {
		if errors.Is(err, models.ErrDuplicateEmail) {
			form.AddFieldError("email", "Email address is already in use")
			data := app.newTemplateData(r)
			data.Form = form
			app.render(w, r, http.StatusUnprocessableEntity, "signup.html", data)
		} else {
			app.serverError(w, r, err)
		}
		return
	}
	// Otherwise add a confirmation flash message to the session confirming that
	// their signup worked.
	app.sessionManager.Put(r.Context(), "flash", "Your signup was successful. Please log in.")
	// And redirect the user to the login page.
	http.Redirect(w, r, "/user/login", http.StatusSeeOther)
}

...

Reinciamos el server y registrate. Recorda las credenciales las vamos a necesitar mas adelante. Si todo salio bien seras redirigido a https://localhost:4000/user/login despues de haber enviado el formulario.

Si intengas volver a registar el mismo email veras el error en el input!

Podes revisar el nuevo registro asi :

sudo mysql 
use snippetbox;

mysql> SELECT * FROM users;
+----+-----------+-----------------+--------------------------------------------------------------+---------------------+
| id | name
| email
| hashed_password
| created
|
+----+-----------+-----------------+--------------------------------------------------------------+---------------------+
| 1 | Nahuel | nahuel@gmail.com | $2a$12$mNXQrOwVWp/TqAzCCyDoyegtpV40EXwrzVLnbFpHPpWdvnmIoZ.Q. | 2023-09-07 07:07:35 |
+----+-----------+-----------------+--------------------------------------------------------------+---------------------+
1 row in set (0.01 sec)

10.3 Informacion adicional

10.3 Usando implementaciones bcrypt de bases de datos

Algunas bases de datos proveen funciones que podes usar para hashear passwords y verificarlos en vez de implementarlo tu mismo en Go como hicimos con el codigo anterior.

Pero probablemente es una buena idea evitar usarlo por dos razones.

  • Tienden a ser vulnerables a side-channel timing attacks debido al tiempo de comparacion de cadenas al menos en PostgreSQL y MySQL.
  • Al menos que seas muy cuidadoso, enviar el password a la db tiene el riesgo de que accidentalmente quee capturada en los logs.Un par de ejemplos conocidos de este caso fueron los passwords que accidentamente capturon los logs de Github y Twitter en 2018.
10.3 Alternativas para revisar la duplicidad en los emails.

Entiendo que el código en nuestro método UserModel.Insert() no es muy elegante y que verificar el error devuelto por MySQL se siente un poco inseguro. ¿Qué sucede si las futuras versiones de MySQL cambian los números de error o el formato de sus mensajes de error?

Una alternativa esa crear un metodo UserModel.EmailTaken() en nuestro modelo el cual revise si el usuario ya existe con un email especifico. Podriamos llamarlo antes de que intentemos insertarlo y agragar un mensaje de error en la validacion.

Esto podria introducir una race condition a nuestra app. Si dos usuarios se registran al mismo tiempo con el mismo email ambos pasaran la validacion sobre la existencia de ese email en la db, pero finalmente solo uno se insertara , dando como resultado a uno de los dos una respuesta 500 Internal Server Error.

Puede parecer algo benigno pero pensar de manera critica el comportamiento de nuestra app es un buen habito mas aun si podemos evitar race conditions.

10.4 User login

En esta seccion vamos a centrarnos en crear la pagina para el inicio de sesion del usuario.

Antes de comencar con la parte principal de esto , vamos a revisar rapidamente el paquete internal/validator y actualicemoslo para soportar errores que no esten asociados a un campo especifico.

Lo usaremos mas adelante cuando enviemos al usuario el mensaje your email address or password is wrong si el login falla, esto es mas seguro que indicar el campo que es incorrecto.

  package main
  
  ... 
  
  // Define a new Validator struct which contains a map of validation error messages
  // for our form fields.
  type Validator struct {
+  	NonFieldErrors []string
  	FieldErrors    map[string]string
  }
  
  // Valid() returns true if the FieldErrors map doesn't contain any entries.
  func (v *Validator) Valid() bool {
+  	return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
  }
  
+  // Create an AddNonFieldError() helper for adding error messages to the new
+  // NonFieldErrors slice.
+  func (v *Validator) AddNonFieldError(message string) {
+  	v.NonFieldErrors = append(v.NonFieldErrors, message)
+  }
  
  ...

Lo siguiente es crear el ui/html/pages/login.html para mostrar el login:

$ touch ui/html/pages/login.html
{{define "title"}}Login{{end}} {{define "main"}}
<form action="/user/login" method="POST" novalidate>
  <!-- Notice that here we are looping over the NonFieldErrors and displaying
them, if any exist -->
  {{range .Form.NonFieldErrors}}
  <div class="error">{{.}}</div>
  {{end}}
  <div>
    <label>Email:</label>
    {{with .Form.FieldErrors.email}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="email" name="email" value="{{.Form.Email}}" />
  </div>
  <div>
    <label>Password:</label>
    {{with .Form.FieldErrors.password}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="password" name="password" />
  </div>
  <div>
    <input type="submit" value="Login" />
  </div>
</form>
{{end}}

Vayamos a cmd/web/handlers.go y creemos la estructura userLoginForm para representar al contenedor de los datos y adaptarlo a nuestro handler userLogin para renderizar el login.

  package main
  
  ... 
  // Create a new userLoginForm struct.
  type userLoginForm struct {
  	Email               string `form:"email"`
  	Password            string `form:"password"`
  	validator.Validator `form:"-"`
  }

 func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
	data := app.newTemplateData(r)
	data.Form = userLoginForm{}
	app.render(w, r, http.StatusOK, "login.html", data)
}  
  ...

Reinicimos el servidor y ya tenemos nuestro login online.

10.4 Verificando los detalles del usuario

El siguiente paso es : como podemos verificar que el email y el password son correctos?

El centro de esta verificacion y la logica estara en UserModel.Authenticate() Necesitamso hacer dos cosas.

  1. Primero debemos recibir el hashed password asociado con el email que tenemos en la tabla users de la db.Si el email no existe en la db regresaremos ErrInvalidCredentials que creamos con anterioridad.
  2. De otra manera, queremos comparar el hashed password que viene de la tabla users con el texto plano que nos envia el usuario con se esta logeando, si no hacen match regresaremods ErrInvalidCredentials. Pero si hacen match queremos retornar el id del usuario desde la db.

Vamos a internal/models/users.go

func (m *UserModel) Authenticate(email, password string) (int, error) {
	// Retrieve the id and hashed password associated with the given email. If
	// no matching email exists we return the ErrInvalidCredentials error.
	var id int
	var hashedPassword []byte
	stmt := "SELECT id, hashed_password FROM users WHERE email = ?"
	err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, ErrInvalidCredentials
		} else {
			return 0, err
		}
	}
	// Check whether the hashed password and plain-text password provided match.
	// If they don't, we return the ErrInvalidCredentials error.
	err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
	if err != nil {
		if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
			return 0, ErrInvalidCredentials
		} else {
			return 0, err
		}
	}
	// Otherwise, the password is correct. Return the user ID.
	return id, nil
}

Nuestro proximo paso es actualizar el handler userLoginPost para que parsee el formulario y llame al metodo UserModel.Authenticate()

Si el login es correcto queremos agregar el id a los datos de session del usuario, en peticiones futuras, sabremos que ya esta autenticado y que usuario es.

Vamos a headers.go

func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
	// Decode the form data into the userLoginForm struct.// Decode the form data into the userLoginForm struct.
	var form userLoginForm
	err := app.decodePostForm(r, &form)
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}
	// Do some validation checks on the form. We check that both email and
	// password are provided, and also check the format of the email address as
	// a UX-nicety (in case the user makes a typo).
	form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
	form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
	form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
	if !form.Valid() {
		data := app.newTemplateData(r)
		data.Form = form
		app.render(w, r, http.StatusUnprocessableEntity, "login.html", data)
		return
	}
	// Check whether the credentials are valid. If they're not, add a generic
	// non-field error message and re-display the login page.
	id, err := app.users.Authenticate(form.Email, form.Password)
	if err != nil {
		if errors.Is(err, models.ErrInvalidCredentials) {
			form.AddNonFieldError("Email or password is incorrect")
			data := app.newTemplateData(r)
			data.Form = form
			app.render(w, r, http.StatusUnprocessableEntity, "login.html", data)
		} else {
			app.serverError(w, r, err)
		}
		return
	}
	// Use the RenewToken() method on the current session to change the session
	// ID. It's good practice to generate a new session ID when the
	// authentication state or privilege levels changes for the user (e.g. login
	// and logout operations).
	err = app.sessionManager.RenewToken(r.Context())
	if err != nil {
		app.serverError(w, r, err)
		return
	}
	// Add the ID of the current user to the session, so that they are now
	// 'logged in'.
	app.sessionManager.Put(r.Context(), "authenticatedUserID", id)
	// Redirect the user to the create snippet page.
	http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
}

El metodo SessionManager.RenewToken() que usamos recien cambiara el ID del usuario actual, pero retendra cualquier data asociada con la sesion. Es buena practica hacerlo antes de un login para mitigar el riesgo de session fixation attack . Para mas infomracion lee OWASP Session Management Cheat Sheet.

Reiniciemos el servidor e iniciemos sesion.

10.5 User logout

Lo unico que tenemos que hacer es quitar “authenticatedUserID” de la sesion

Al mismo tiempo es buena practica renover el ID de la sesion otravez, y tambien agregaremos un flash message para confirmarle al usuario que se ha deslogeado.

Actulicemos userLogoutPost para que haga exactamente eso.

func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
	// Use the RenewToken() method on the current session to change the session
	// ID again.
	err := app.sessionManager.RenewToken(r.Context())
	if err != nil {
		app.serverError(w, r, err)
		return
	}
	// Remove the authenticatedUserID from the session data so that the user is
	// 'logged out'.
	app.sessionManager.Remove(r.Context(), "authenticatedUserID")
	// Add a flash message to the session to confirm to the user that they've been
	// logged out.
	app.sessionManager.Put(r.Context(), "flash", "You've been logged out successfully!")
	// Redirect the user to the application home page.
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

Guardemos y reiniciemos .

10.6 Autorizacion de usuario

Ahora que tenemos nuestr auth creada, tenemos que hacer algo util con esa informacion. Haremos lo siguiente:

  1. Solo los autenticados puede crear un nuevo snippet.
  2. La barra de navegacion debe mostrar los links pertinentes dependiendo de si el usuario esta logeado o no.
  • El usuario autenticaddo debe ser los links “home”, “create snippet” y “logout”
  • El usuario no autenticado debe ver “home”,“signup” y “login”.

Como mencionamos antes podemos verificar en cada peticion si el usuario esta autenticado o no viendo si tienen el valor “authenticatedUserID” en los datos de sesion.

Comencemos con cmd/web/helpers.go y agregemos el helper isAuthenticated() que retornara el status de la autenticacion asi:

package main
...
// Return true if the current request is from an authenticated user, otherwise
// return false.
func (app *application) isAuthenticated(r *http.Request) bool {
return app.sessionManager.Exists(r.Context(), "authenticatedUserID")
}

Eso fue bastante limpio. Ahora podemos saber si la peticion que esta en camino viene de un usuario autenticado o no, simplemente llamando al helper isAuthenticated().

Lo que sigue es tenerlo disponible en nuestro HTML para poder mostrar la informacion del nav dependiendo de este estado.

Vamos a agregar un nuevo campo IsAuthenticated a la estructura templateData.

package main

...

type templateData struct {
	CurrentYear     int
	Snippet         models.Snippet
	Snippets        []models.Snippet
	Form            any
	Flash           string
	IsAuthenticated bool
}

...

Y lo segundo es actualizar el helper newTemplateData() para que esa informacion este automaticamente agregada en la estructura templateData en cada render.

package main

...

func (app *application) newTemplateData(r *http.Request) *templateData {
	return &templateData{
		CurrentYear:     time.Now().Year(),
		Flash:           app.sessionManager.PopString(r.Context(), "flash"),
		IsAuthenticated: app.isAuthenticated(r),
	}

...

Una vez hecho esto vamos a usarlo en ui/html/partials/nav.html con la accion {{if .IsAuthenticated}}

  {{define "nav"}}
  <nav>
    <div>
      <a href="/">Home</a>
+      <!-- Toggle the link based on authentication status -->
+      {{if .IsAuthenticated}}
      <a href="/snippet/create">Create snippet</a>
+      {{end}}
    </div>
    <div>
+      <!-- Toggle the links based on authentication status -->
+      {{if .IsAuthenticated}}
       <form action="/user/logout" method="POST">
        <button>Logout</button>
      </form>
+     {{else}}
      <a href="/user/signup">Signup</a>
      <a href="/user/login">Login</a>
      {{end}}
    </div>
  </nav>
  {{end}}

Guardamos todos los cambios y reiniciamos.

10.6 Restringiendo el acceso

Por ahora estamos ocultado el link create snippet en la navegacion pero cualquier usurio sin autenticacion prodria ir directamente a https://localhost:4000/snippet/create y crearlo.

Vamos a arreglar esto para que si algun usurio intenta acceder a la ruta /snippet/create sea redireccionado a /user/login.

La manera simple de hacer esto es con un middleware.Abri cmd/web/middleware.go y crearemos requireAuthentication() siguiendo el mismo patron que en anterioes post.

package main

... 

func (app *application) requireAuthentication(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// If the user is not authenticated, redirect them to the login page and
		// return from the middleware chain so that no subsequent handlers in
		// the chain are executed.
		if !app.isAuthenticated(r) {
			http.Redirect(w, r, "/user/login", http.StatusSeeOther)
			return
		}
		// Otherwise set the "Cache-Control: no-store" header so that pages
		// require authentication are not stored in the users browser cache (or
		// other intermediary cache).
		w.Header().Add("Cache-Control", "no-store")
		// And call the next handler in the chain.
		next.ServeHTTP(w, r)
	})
}

Ahora podemos agregar el middleware en cmd/web/routes.go para proteger las rutas especificas.

En nuestro caso protegeremos GET /snippet/create y POST /snippet/create, tampoco tiene sentido poder entrar al logout si no estas logeado, asi que tambien protegeremos POST /user/logout.

Vamos a seprar nuestras rutas en dos grupos.

El primer grupo son las que no tienen proteccion y usan la cadena dynamic middleware. El segundo son las protegidas y usaremos el nuevo middleware en la cadena, que consta del middleware dyanmic junto con el middlware requireAuthentication().

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

	// Unprotected application routes using the "dynamic" middleware chain.
	dynamic := alice.New(app.sessionManager.LoadAndSave)

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

	// Protected (authenticated-only) application routes, using a new "protected"
	// middleware chain which includes the requireAuthentication middleware.
	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)
}

Guardamos y reiniciamos el servidor.

Si intentamod ingredsar a https://localhost:4000/snippet/create estando deslogeados nos redireccionara a login

$ curl -ki -X POST https://localhost:4000/snippet/create
HTTP/2 303
content-security-policy: default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com
location: /user/login
referrer-policy: origin-when-cross-origin
vary: Cookie
x-content-type-options: nosniff
x-frame-options: deny
x-xss-protection: 0
content-length: 0
date: Thu, 07 Sep 2023 14:39:51 GMT

10.7 Proteccion CSRF

En esta seccion vamos a ver como proteger nuestra app de cross-site request forgery (CSRF) attacks.

Si no estas familiarizado con este tipo de ataques podes leer mas aca

En nuestra app, los principales riesgos son:

  • Un usuario se logea, nuestra cookie de sesion persiste por 12 horas, entonces permaneceran conectados incluso si salen de nuestra app.
  • El usuario se dirige a otra web, la cual tiene codigo malisioso que envia una peticion cross-site hacia nuestro endpoint POST /snippet/create para agregar un snippet a nuestra db.
  • Como la peticion incluye las cookies de sesion, nuestra app interpretara que la peticion viene desde un usuario logeado y procesara la peticion como si fuera un usuario con privilegios. Sin que el usuario lo sepa, se creeo un nuevo snippet en nuestra DB.

Asi es como de los ataques traudicionales CSRF, tu app tambien puede estar expuesta en los inicios de sesion y en los logout.

10.7 Cookies SameSite

Una forma de mitigar estos ataques CSRF es estar seguros de qeu el atributo SameSite esta setaedo corectamente en nuestra cookie de sesion.

Por default el paquete alexedwards/scs setea SameSite=Lax en la cookie de sesion. significa que la cookie de sesión solo se enviará junto con las solicitudes que provengan del mismo sitio web (origen). En otras palabras, la cookie de sesión no se enviará con las solicitudes que provengan de un sitio web diferente (sitio cruzado). Esto afecta a las peticiones POST, PUT o DELETE.

Siempre y cuando nuestra app use el metodo POST para cualquier cambio de estado, como lo hacemos para nuestro login,signup,logout y create snippet. Esto significa que la cookie de sesion no sera enviada en esas peticiones si vienen de otro sitio que no sea el nuestro previniendo asi ataques CSRF.

10.7 Mitigacion Token-based

Para mitigar el riesgo de CSRF para todos los usuarios tambien tenemos que implementar alguna forma de token check. Como en los manejadores de sesion y los hasheadores de password , muchas cosas pueden salir mal, es por eso que es mas seguro usar paquetes de terceros que hagan ese trabajo por nosotros.

Dos de los paquetes mas populares pra detener ataques CSRF en aplicaiones web de Go son gorilla/csrf y justinas/nosurf . Ambas hacen casi lo mismo, usan el patron double-submit cookie para prevenir ataques. En este patron ubn CSRF token aleatorio es generado y enviaado al usuario en una cookie CSRF. Este token CSRF es agregado de manera oculta al campo del formulario html que es potencialmente vulnerable a CSRF. Cuando el formulario es enviado, ambos paquetes usan un middleware para revisar el valor de campo ocultoy el valor de la cookie para ver si hacen match.

Usaremos en nuestro caso justinas/nosurf.

$ go get github.com/justinas/nosurf@v1
go: downloading github.com/justinas/nosurf v1.1.1
go get: added github.com/justinas/nosurf v1.1.1

10.7 Usando el paquete nosurf

Para usar justinas/nosurf, vamos a cmd/web/middleware.go y creemos un neuvo middleware llamado noSurf() asi :

// Create a NoSurf middleware function which uses a customized CSRF cookie with
// the Secure, Path and HttpOnly attributes set.
func noSurf(next http.Handler) http.Handler {
	csrfHandler := nosurf.New(next)
	csrfHandler.SetBaseCookie(http.Cookie{
		HttpOnly: true,
		Path:     "/",
		Secure:   true,
	})
	return csrfHandler
}

Uno de los formularios que tenemos que proteger es el logout, el cual esta en nav.html, como este partial puede aparecer en cualquier parte de nuestra app tenemos que agregar el middleware a todas las rutas menos a /static/*filepath.

Vamos a cmd/web/routes.go y actualicemoslop para que use noSurf() en la cadena 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))
  
+  	// Use the nosurf middleware on all our 'dynamic' routes.
+  	dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf)
  
  	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)
}

En este punto si intentas hacer un submit de uno de los formulario deberias recibir la respuesta 400 Bad Request ya que esta pasando por el middleware noSurf() pero aun no configuamos los formularios para que tengan el token oculto.

Para tener este token a mano tenemos que usar la funcion nosurf.Token() y ponerlo en el campo oculto csrf_token en cada uno de nuestros formularios. Asi que el siguiente paso es agregar el campo CSRFToken a la estructura templateData.

package main

import (
	"html/template"
	"path/filepath"
	"time"

	"github.com/nahueldev23/snippetbox/internal/models"
)

type templateData struct {
	CurrentYear     int
	Snippet         models.Snippet
	Snippets        []models.Snippet
	Form            any
	Flash           string
	IsAuthenticated bool
	CSRFToken       string
}

...

Y como el logout puede aparecer en todas las paginas, tienen sentido agregar el CSRF token al templateData automaticamente a traves del helper newTemplateData(). Esto significa que estara disponible para nuestros templates cada vez que rendericemos una pagina.

Vamos a cmd/web/helpers.go

package main

import (
	"bytes"
	"errors"
	"fmt"
	"net/http"
	"time"

	"github.com/go-playground/form/v4"
	"github.com/justinas/nosurf"
)
...

func (app *application) newTemplateData(r *http.Request) *templateData {
	return &templateData{
		CurrentYear:     time.Now().Year(),
		Flash:           app.sessionManager.PopString(r.Context(), "flash"),
		IsAuthenticated: app.isAuthenticated(r),
		CSRFToken:       nosurf.Token(r),
	}
}
...

Como paso final tenemos que actualizar todos nuestros formularios para que incluyan el campo oculto csrf token.

{{define "title"}}Create a New Snippet{{end}} {{define "main"}}
<form action="/snippet/create" method="POST">
  <!-- Include the CSRF token -->
  <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
  <div>
    <label>Title:</label>
    <!-- Use the `with` action to render the value of .Form.FieldErrors.title
if it is not empty. -->
    {{with .Form.FieldErrors.title}}
    <label class="error">{{.}}</label>
    {{end}}
    <!-- Re-populate the title data by setting the `value` attribute. -->
    <input type="text" name="title" value="{{.Form.Title}}" />
  </div>
  <div>
    <label>Content:</label>
    <!-- Likewise render the value of .Form.FieldErrors.content if it is not
empty. -->
    {{with .Form.FieldErrors.content}}
    <label class="error">{{.}}</label>
    {{end}}
    <!-- Re-populate the content data as the inner HTML of the textarea. -->
    <textarea name="content">{{.Form.Content}}</textarea>
  </div>
  <div>
    <label>Delete in:</label>
    <!-- And render the value of .Form.FieldErrors.expires if it is not empty. -->
    {{with .Form.FieldErrors.expires}}
    <label class="error">{{.}}</label>
    {{end}}
    <!-- Here we use the `if` action to check if the value of the re-populated
expires field equals 365. If it does, then we render the `checked`
attribute so that the radio input is re-selected. -->
    <input type="radio" name="expires" value="365" {{if (eq .Form.Expires 365)}}checked{{end}} />
    One Year
    <!-- And we do the same for the other possible values too... -->
    <input type="radio" name="expires" value="7" {{if (eq .Form.Expires 7)}}checked{{end}} />
    One Week
    <input type="radio" name="expires" value="1" {{if (eq .Form.Expires 1)}}checked{{end}} />
    One Day
  </div>
  <div>
    <input type="submit" value="Publish snippet" />
  </div>
</form>
{{end}}

{{define "title"}}Login{{end}} {{define "main"}}
<form action="/user/login" method="POST" novalidate>
  <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
  {{range .Form.NonFieldErrors}}
  <div class="error">{{.}}</div>
  {{end}}
  <div>
    <label>Email:</label>
    {{with .Form.FieldErrors.email}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="email" name="email" value="{{.Form.Email}}" />
  </div>
  <div>
    <label>Password:</label>
    {{with .Form.FieldErrors.password}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="password" name="password" />
  </div>
  <div>
    <input type="submit" value="Login" />
  </div>
</form>
{{end}}

{{define "title"}}Signup{{end}} {{define "main"}}
<form action="/user/signup" method="POST" novalidate>
  <!-- Include the CSRF token -->
  <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
  <div>
    <label>Name:</label>
    {{with .Form.FieldErrors.name}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="text" name="name" value="{{.Form.Name}}" />
  </div>
  <div>
    <label>Email:</label>
    {{with .Form.FieldErrors.email}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="email" name="email" value="{{.Form.Email}}" />
  </div>
  <div>
    <label>Password:</label>
    {{with .Form.FieldErrors.password}}
    <label class="error">{{.}}</label>
    {{end}}
    <input type="password" name="password" />
  </div>
  <div>
    <input type="submit" value="Signup" />
  </div>
</form>
{{end}}

{{define "nav"}}
<nav>
  <div>
    <a href="/">Home</a>
    <!-- Toggle the link based on authentication status -->
    {{if .IsAuthenticated}}
    <a href="/snippet/create">Create snippet</a>
    {{end}}
  </div>
  <div>
    <!-- Toggle the links based on authentication status -->
    {{if .IsAuthenticated}}
    <form action="/user/logout" method="POST">
      <!-- Include the CSRF token -->
      <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
      <button>Logout</button>
    </form>
    {{else}}
    <a href="/user/signup">Signup</a>
    <a href="/user/login">Login</a>
    {{end}}
  </div>
</nav>
{{end}}

Si vamos al nuesta app, vamos a poder ver que los formularios tienen el input oculto con el token.

Ahora si intentas enviar los datos de algun formulario deberian funcionar correctamente.

10.7 Informacion adicional

10.7 SameSite “strict” setting

Si queres cambiar la cookie de sesion para que use la configuracion SameSite=Strict en vez del por default SameSite=LAX Podes hacerlo asi:

sessionManager := scs.New()
sessionManager.Cookie.SameSite = http.SameSiteStrictMode

Pero es importante ser consciente que usar SameSite=Strict bloqueara la cookie de sesion que sean enviadas por el navegador del usuario cuando se haga un cross-site incluyendo peticiones seguras como GET Y HEAD.

Si bien puede parecer seguro y lo es. Lo malo es que la cookie no se enviara cuando un usuario haga click a un enlace de otra web que rediriga al tuyo, en caso de que ya se haya logeado nuestra app lo tratara como a un usuario que no esta autenticado.

Asi que si tu app potencialmente tiene links en otras web, o emails ,etc. Entonces SameSite=LAX es una opcion apropiada.

SameSite cookies y TLS 1.3

Antes dijimos que no podemos confiar solamente en el atributo SameSite para prevenir ataques CSRF porque no es totalmente compatible con todos los navegadores.

Pero hay una exepcion a esta regla, debido a que no hay ningun navegador que admita TLS 1.3 y no admita cookies SameSite.

En otras palabras si hicieras que TLS 1.3 sea el minimo de version soprtada, entonces todos los navegadores capaces de usar la app soportarian la cookie SameSite.

tlsConfig := &tls.Config{
  MinVersion: tls.VersionTLS13,
}

Mientras permitas peticiones HTTPS en tu aplicacion y hagas cumplir el minimo de la version TLS , no necesitas hacer mitigaciones adicionales contra ataques CSRF ( como usar el paquete justinas/nosurf). Solo estate seguro de que siempre:

  • Tenes seteado en la cookie de sesion SameSite=Lax o SameSite=Strict.
  • Usas los metodos POST , PUT o DELETE para cualquier cambio de estado en las peticiones.