Part 4 - Nuxnuxx/showcase_go GitHub Wiki

Authentification

For the authentification we will use JsonWebToken,

Jwt will be use here to store a json object on the client side which will be signed by a secret key and we will check that it is a valid token on back to do Authentification

Echo provide a built-in middleware to get started with JWT.

go get github.com/labstack/echo-jwt/v4

After that we have all we need to our authentification, so we can create a new services called auth.services.go and start up it

//Filename: internal/services/auth.services.go

func NewAuthServices(u User, uStore database.Store, secretKey string) *AuthService {

	return &AuthService{
		User:      u,
		UserStore: uStore,
		SecretKey: []byte(secretKey),
	}
}

type AuthService struct {
	User      User
	UserStore database.Store
	SecretKey []byte
}

type User struct {
	ID 		 int    `json:"id"`
	Email    string `json:"email"`
	Username string `json:"username"`
	Password string `json:"password"`
}

After that we can add the first important function which have to goal to create a new user

//Filename: internal/services/auth.services.go

func (as *AuthService) CreateUser(u User) error {
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), 8) // Hash the password because we are not criminal
	if err != nil {
		return err
	}

	stmt := `INSERT INTO users(email, password, username) VALUES($1, $2, $3)`

	_, err = as.UserStore.Db.Exec(
		stmt,
		u.Email,
		string(hashedPassword),
		u.Username,
	)

	return err
}

Then we can create the handlers that come with it auth.handlers.go and start up it too

type AuthServices interface {
	CreateUser(user services.User) error
}

func NewAuthHandler(as AuthServices) *AuthHandler {

	return &AuthHandler{
		AuthServices: as,
	}
}

type AuthHandler struct {
	AuthServices AuthServices
}

And add a handler that first show the form to the user

//Filename: internal/services/auth.services.go

func (au *AuthHandler) Register(c echo.Context) error {
	return renderView(c, authviews.RegisterIndex())
}

Then create the view we use in the return, create a new folder in views called auth_views and a file named auth.register.templ then create the components has we always has done

//Filename: internal/views/auth_views/auth.register.templ

templ Register() {
	<div class="bg-white p-8 rounded shadow-md w-full max-w-md">
		<h2 class="text-2xl font-semibold mb-4">User Registration</h2>
		<form action="" method="post">
			<div class="mb-4">
				<label for="email" class="block text-gray-700">Email:</label>
				<input type="email" id="email" name="email" required class="form-input mt-1 block w-full" />
			</div>
			<div class="mb-4">
				<label for="username" class="block text-gray-700">Username:</label>
				<input type="text" id="username" name="username" required class="form-input mt-1 block w-full" />
			</div>
			<div class="mb-4">
				<label for="password" class="block text-gray-700">Password:</label>
				<input type="password" id="password" name="password" required class="form-input mt-1 block w-full" />
			</div>
			<div class="mb-4">
				<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Register</button>
			</div>
		</form>
	</div>
}

templ RegisterIndex() {
	@layout.Base() {
		@Register()
	}
}

From that we can add those new features to the routes by adding the new handlers and create new endpoint

//Filename: internal/handlers/routes.go

func SetupRoutes(e *echo.Echo, gh *GamesHandler, as *AuthHandler) {
	e.GET("/", HomeHandler)
	e.GET("/list", gh.GetGamesByPage)
	e.GET("/game/:id", gh.GetGameById)

	e.GET("/register", as.Register)
}

Now it show an error in the main, that because we need to initiate the new service and handler to pass it to the setupRoutes function

//Filename: main.go
gameHandler := ...

authServices := services.NewAuthServices(services.User{}, store, os.Getenv("SECRET_KEY"))
authHandler := handlers.NewAuthHandler(authServices)

handlers.SetupRoutes(e, gameHandler, authHandler)

And add a new SECRET_KEY in the .env file too.

For now our form doesn't do anything so let's make it functional, add a post endpoint to the setupRoutes

//Filename: internal/handlers/routes.go

e.POST("/register", as.Register)

But what we use the same handler for the post and get, and yes we will handle the post send in the same, let's check how it works

//Filename: internal/services/auth.services.go

func (au *AuthHandler) Register(c echo.Context) error {
	if c.Request().Method == "POST" {
		user := services.User{
			Email:    c.FormValue("email"),
			Username: c.FormValue("username"),
			Password: c.FormValue("password"),
		}

		err := au.AuthServices.CreateUser(user)

		if err != nil {
			return renderView(c, errors_pages.Error500Index())
		}

		return c.Redirect(http.StatusSeeOther, "/")
	}
	return renderView(c, authviews.RegisterIndex())
}

Here we get the data we get from the form and pass it to a struct user to then create it with our function.

We still have some work to do on the view, just to add the endpoint in which we want to send the post request here <form action="/register" method="post">.

Now it works you should be redirect to the homepage.

But we still have to handle errors, use JWT to create protected routes and store the token on the client side.

Handle errors on form

We will use the built-in validator from echo to handle those, we need to add our constraints to the struct

Before that just need to install the validator framework

go get github.com/go-playground/validator
//Filename: internal/services/auth.services.go

type User struct {
	Email    string `json:"email" validate:"required,email"`
	Username string `json:"username" validate:"required,min=3,max=20"`
	Password string `json:"password" validate:"required,min=8,max=20"`
}

This is self-explanatory, we check that the email is a valid email, and limit the size of username and password.

Now we create a custom validator in a new file utils.go in the services folder

//Filename: internal/services/utils.go

type (
	CustomValidator struct {
		Validator *validator.Validate
	}
)

func (cv *CustomValidator) Validate(i interface{}) error {
	if err := cv.Validator.Struct(i); err != nil {
		return err
	}
	return nil
}

Now we need to make the echo server aware of the new validator we just create.

//Filename: main.go
e.Validator = &services.CustomValidator{Validator: validator.New()}

gameServices := ....

and now we can return a 400 if the value don't fill the constraint we set before

//Filename: internal/services/auth.services.go
user := services.User{
    ////
}

if err := c.Validate(user); err != nil {
    return renderView(c, errors_pages.Error400Index()) }

But it is not enough and the user cannot know which error has cause it.

So now we have a error which we can cast to validateErrors object and use it to build the errors message to the user we need one function in the utils.go services file

//Filename: internal/services/utils.go

type HumanErrors struct {
    Value 	 string
    Error 	 string
}

func CreateHumanErrors(err error) map[string]HumanErrors {
	errors := make(map[string]HumanErrors)

	for _, v := range err.(validator.ValidationErrors) {
		error := strings.Builder{}
		error.WriteString(fmt.Sprintf("%s should be %s %s",
			strings.Split(v.Namespace(), ".")[1],
			v.Param(),
			v.Tag(),
		))

		errors[strings.ToLower(v.Field())] = HumanErrors{
			Value: v.Value().(string),
			Error: error.String(),
		}
	}

	return errors
}

This function will help us get a map and not a simple error which give us more human readable error for the user and the old value if we need to put it back to the input.

And modify the inner if of validation to return the register components with the map of errors

//Filename: internal/services/auth.services.go

if err := c.Validate(user); err != nil {
    humanErrors := services.CreateHumanErrors(err)

    return renderView(c, authviews.Register(humanErrors))
}

now we just need to modify some part of the view and it should work fine

//Filename: internal/views/auth_views/auth.register.templ

templ Register(humanErrors map[string]services.HumanErrors) {
		<form hx-post="/register" hx-boost="true" hx-swap="outerHTML">
			<div class="mb-4">
				<label for="email" class="block text-gray-700">Email:</label>
				<input type="email" id="email" name="email" required class="form-input mt-1 block w-full" />
				if human, ok := humanErrors["email"]; ok {
					<div class="text-red-500 text-sm">{human.Error}</div>
				}
			</div>
			<div class="mb-4">
				<label for="username" class="block text-gray-700">Username:</label>
				<input type="text" id="username" name="username" required class="form-input mt-1 block w-full" />
				<div class="text-sm"> 3 - 20 characters, letters, numbers, and underscores only</div>
				if human, ok := humanErrors["username"]; ok {
					<div class="text-red-500 text-sm">{human.Error}</div>
				}
			</div>
			<div class="mb-4">
				<label for="password" class="block text-gray-700">Password:</label>
				<input type="password" id="password" name="password" required class="form-input mt-1 block w-full" />
				<div class="text-sm"> 8 - 50 characters, at least one letter, one number, and one special character</div>
				if human, ok := humanErrors["password"]; ok {
					<div class="text-red-500 text-sm">{human.Error}</div>
				}
			</div>
			<div class="mb-4">
				<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Register</button>
			</div>
		</form>
}

templ RegisterIndex() {
	@layout.Base() {
		<div class="bg-white p-8 rounded shadow-md w-full max-w-md">
			<h2 class="text-2xl font-semibold mb-4">User Registration</h2>
			@Register(nil)
		</div>
	}
}

We use htmx to rerender only the form if there is an error and we can check validation as same as in other languages.

Now we can do all the part of JWT when we register in our app.

First we need to create the token and store it client-side after we are sure the user has really benn created.

We have forgot something, we don't check if the email already exist and it should cause a error 500 when creating a user that already exist, let's get on it.

//Filename: internal/services/auth.services.go

func (as *AuthService) CheckEmail(email string) (User, error) {

	query := `SELECT email, password, username FROM users
		WHERE email = ?`

	stmt, err := as.UserStore.Db.Prepare(query)
	if err != nil {
		return User{}, err
	}

	defer stmt.Close()

	as.User.Email = email
	err = stmt.QueryRow(
		as.User.Email,
	).Scan(
		&as.User.Email,
		&as.User.Password,
		&as.User.Username,
	)

	if err != nil {
		return User{}, err
	}

	return as.User, nil
}

And we now use it to check that the user already exist or not in our handlers.

if err := c.Validate(user); err != nil {
    ////
}

userInDatabase, err := au.AuthServices.CheckEmail(user.Email)

if userInDatabase != (services.User{}) {
    humanErrors := map[string]services.HumanErrors{
        "email": {
            Error: "Email already exists",
            Value: user.Email,
        },
    }

    return renderView(c, authviews.Register(humanErrors))
}

Now the user will receive a error to show him that he already as an account.

//Filename: internal/handlers/auth.handlers.go
func (au *AuthHandler) Register(c echo.Context) error {
    ////

    token, err := au.AuthServices.GenerateToken(user)

    if err != nil {
        return renderView(c, errors_pages.Error500Index())
    }

    cookie := http.Cookie{
        Name: "token",
        Value: token,
        Path:    "/",
        HttpOnly: true,
        Secure: true,
        Expires: time.Now().Add(24 * time.Hour),
    }

    c.SetCookie(&cookie)

    // INFO: To redirect when using HTMX you need to set the HX-Redirect header
    c.Response().Header().Set("HX-Redirect", "/")
    c.Response().WriteHeader(http.StatusOK)
    return nil
}

We also need the GenerateToken function we just use to create the token

//Filename: internal/services/auth.services.go

type JwtCustomClaims struct {
	Email    string `json:"email"`
	Username string `json:"username"`
	jwt.RegisteredClaims
}

func (as *AuthService) GenerateToken(user User) (string, error) {
	claims := &JwtCustomClaims{
		Email:    user.Email,
		Username: user.Username,
		RegisteredClaims: jwt.RegisteredClaims{
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
		},
	}

	signedToken, err := token.SignedString(as.SecretKey)

	if err != nil {
		return "", err
	}

	return signedToken, nil
}

The token JWT work has defined here, we create claims which are the data that will contains the tokenthen we register a timestamp of now and and an expire time 24 hours later, to make sure that if the token leaks it will expire at a times, after that we sign it with our secret key.

Now that we have the cookies store client side we need to create a protected routes and check that the token is valid.

We will also need the secret key from the services of auth.

//Filename: internal/services/auth.services.go

func (as *AuthService) GetSecretKey() string {
	return as.SecretKey
}

Also put in the interface of the handlers

//Filename: internal/handlers/auth.handlers.go

type AuthServices interface {
	GetSecretKey() string
    ///
}

Now we can create our new routes which will be protected

//Filename: internal/handlers/routes.go

protectedRoute := e.Group("/protected", echojwt.WithConfig(echojwt.Config{
    NewClaimsFunc: func(c echo.Context) jwt.Claims {
        return new(services.JwtCustomClaims)
    },
    SigningKey: as.AuthServices.GetSecretKey(),
    TokenLookup: "cookie:token",
}))

protectedRoute.GET("/", HomeHandler)

Here we pass the new claims type we just create, the signing key from the auth services to decrypt it, and where to find the token in the incoming request.

And you can also refacto all the place where you have a return renderView(c, ErrorXXX) to add just before the good status on the Response

// Everywhere
c.Response().WriteHeader(correct status)
return renderView(/////)

Now we can do the most simple routes, a profil page. first modify the routes protected by that

//Filename: internal/handlers/routes.go
protectedRoute.GET("/", HomeHandler) -> protectedRoute.GET("/profil", as.Profil)

Then we can create our new handler

//Filename: internal/handlers/auth.handlers.go

func (au *AuthHandler) Profil (c echo.Context) error {
	token, ok := c.Get("user").(*jwt.Token)

	if !ok {
		log.Errorf("Error getting claims from token: %v", token)
		return renderView(c, errors_pages.Error401Index())
	}

	claims, ok := token.Claims.(*services.JwtCustomClaims)

	if !ok {
		log.Errorf("Error getting claims from token: %v", token)
		return renderView(c, errors_pages.Error401Index())
	}

	user := services.User{
		Email:    claims.Email,
		Username: claims.Username,
	}

	return renderView(c, authviews.ProfilIndex(user))
}

Now we need to add a 401 page to the error pages.

After that we can also create our profilIndex.

//Filename: internal/views/auth_views/auth.register.templ

templ Profil(user services.User){
 <div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
    <div class="p-6">
      <h2 class="text-2xl font-semibold text-gray-800 mb-2">Profile Information</h2>
      <div class="space-y-4">
        <div>
          <label for="username" class="block text-sm font-medium text-gray-700">Username</label>
          <p class="text-lg font-semibold text-gray-900" id="username">{user.Username}</p>
        </div>
        <div>
          <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
          <p class="text-lg font-semibold text-gray-900" id="email">{user.Email}</p>
        </div>
      </div>
    </div>
  </div>
}

templ ProfilIndex(user services.User){
	@layout.Base(){
		@Profil(user)
	}
}

And modify the navbar to point to the good endpoint and it will work fine.

So let's do the login page now, so create a new handler.

//Filename: internal/handlers/auth.handlers.go

func (au *AuthHandler) Login(c echo.Context) error {
	return renderView(c, authviews.LoginIndex())
}

And create the form that come with it in a file auth.login.templ in the auth views folder.

//Filename: internal/views/auth_views/auth.login.templ

templ Login(humanErrors map[string]services.HumanErrors) {
		<form hx-post="/register" hx-boost="true" hx-swap="outerHTML">
			<div class="mb-4">
				<label for="email" class="block text--700">Email:</label>
				<input type="email" id="email" name="email" required class="form-input mt-1 block w-full" />
				if human, ok := humanErrors["email"]; ok {
					<div class="text-red-500 text-sm">{human.Error}</div>
				}
			</div>
			<div class="mb-4">
				<label for="password" class="block text-white-700">Password:</label>
				<input type="password" id="password" name="password" required class="form-input mt-1 block w-full" />
				<div class="text-sm"> 8 - 50 characters, at least one letter, one number, and one special character</div>
				if human, ok := humanErrors["password"]; ok {
					<div class="text-red-500 text-sm">{human.Error}</div>
				}
			</div>
			<div class="mb-4">
				<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Register</button>
			</div>
		</form>
}

templ LoginIndex() {
	@layout.Base() {
		<div class="p-8 rounded shadow-md w-full max-w-md mx-auto">
			<h2 class="text-2xl font-semibold mb-4">User Registration</h2>
			@Login(nil)
		</div>
	}
}

Then we declare it in the setupRoutes function.

//Filename: internal/handlers/routes.go

e.GET("/login", as.Login)

Notice, you can go to login and register page, even if you are connected, let's fix that with a middleware.

//Filename: internal/handlers/auth.handlers.go

func (au *AuthHandler) CheckAlreadyLogged(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		token, err := c.Cookie("user")

		if err != nil {
			fmt.Println(err)
			return next(c)
		}

		if token.Value != "" {
			fmt.Println(token)
			return c.Redirect(http.StatusSeeOther, "/")
		}

		return next(c)
	}
}

Now we need to include every routes that is use to connect with this middleware in the setupRoutes

//Filename: internal/handlers/routes.go

authRouter := e.Group("/auth", as.CheckAlreadyLogged)
authRouter.GET("/register", as.Register)
authRouter.POST("/register", as.Register)
authRouter.GET("/login", as.Login)

And everywhere there is /register to redirect now to /auth/register.

We also need to redirect if the user is not connected and try to go profil or other routes that need an account to access it.

So we need to create a errror handler for the echojwt.Config

//Filename: internal/handlers/auth.handlers.go

func (au *AuthHandler) CheckNotLogged(c echo.Context, err error) error {
	token, ok := c.Get("user").(string)

	// Means the user is not connected
	if !ok {
		return c.Redirect(http.StatusSeeOther, "/auth/register")
	}

	// Means the user is not connected
	if token == "" {
		return c.Redirect(http.StatusSeeOther, "/auth/register")
	}

	return nil
}

And add it to the config.

//Filename: internal/handlers/routes.go

protectedRoute := e.Group("/protected", echojwt.WithConfig(echojwt.Config{
    NewClaimsFunc: func(c echo.Context) jwt.Claims {
        return new(services.JwtCustomClaims)
    },
    ErrorHandler: as.CheckNotLogged,
    ////
}))

Now if we try to click on profil we will be redirect to the register page.

So le'ts make this login functional now.

First we can make a button Already signed up on the register page and we will do the form logic later on.

//Filename: internal/views/auth_views/auth.register.templ

<div class="mb-4">
  <a
    href="/auth/login"
    class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
    >Already registered</a
  >
</div>

Add it to the login Form, and now we can do the logic to connect.

//Filename: internal/handlers/auth.handlers.go

if c.Request().Method == "POST" {
    user, err := ah.AuthServices.CheckEmail(c.FormValue("email"))

    // If the user is not found
    if err != nil {
        error := map[string]services.HumanErrors{
            "email": {
                Error: "Email not found",
                Value: c.FormValue("email"),
            },
        }

        return renderView(c, authviews.Login(error))
    }

    err = bcrypt.CompareHashAndPassword(
        []byte(user.Password),
        []byte(c.FormValue("password")),
    )

    // If the password is not correct
    if err != nil {
        error := map[string]services.HumanErrors{
            "internal": {
                Error: "Invalid Credentials",
                Value: "",
            },
        }

        return renderView(c, authviews.Login(error))
    }

    token, err := ah.AuthServices.GenerateToken(user)

    if err != nil {
        c.Response().WriteHeader(http.StatusInternalServerError)
        return renderView(c, errors_pages.Error500Index())
    }

    cookie := http.Cookie{
        Name:    "user",
        Value:   token,
        Path:    "/",
        Secure:  true,
        HttpOnly: true,
        Expires: time.Now().Add(24 * time.Hour),
    }

    c.SetCookie(&cookie)

    c.Response().Header().Set("HX-Redirect", "/")
    c.Response().WriteHeader(http.StatusOK)
    return nil
}

And add a POST request to the routes for the same handle as we have done with the register.

Now we modify the login template to follow what we have done.

//Filename: internal/views/auth_views/auth.login.templ

<form hx-post="/auth/login" hx-boost="true" hx-swap="outerHTML">
  <div class="mb-4">
    <label for="email" class="block text--700">Email:</label>
    <input
      type="email"
      id="email"
      name="email"
      required
      class="form-input mt-1 block w-full"
    />
    if human, ok := humanErrors["email"]; ok {
    <div class="text-red-500 text-sm">{human.Error}</div>
    }
  </div>
  <div class="mb-4">
    <label for="password" class="block text-white-700">Password:</label>
    <input
      type="password"
      id="password"
      name="password"
      required
      class="form-input mt-1 block w-full"
    />
    <div class="text-sm">
      8 - 50 characters, at least one letter, one number, and one special
      character
    </div>
  </div>
  <div class="mb-4">
    <button
      type="submit"
      class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
    >
      Login
    </button>
  </div>
  if human, ok := humanErrors["internal"]; ok {
  <div class="text-red-500 text-sm">{human.Error}</div>
  }
</form>

we can also put a logout button on the profil page.

//Filename: internal/views/auth_views/auth.register.templ

<div class="p-6">
  <form action="/auth/logout" method="post">
    <button
      type="submit"
      class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
    >
      Logout
    </button>
  </form>
</div>

And we need to add a endpoint to delete the cookie.

//Filename: internal/handlers/routes.go

authRouter := e.Group("/auth")
authRouter.POST("/logout", as.Logout)
authRouter.Use(as.CheckLogged)
authRouter.GET("/register", as.Register)
authRouter.POST("/register", as.Register)
authRouter.GET("/login", as.Login)
authRouter.POST("/login", as.Login)

We dont check if the user is logged on the endpoint logout because the endpoint doesn't contains sensitive logic.

Now all we have do to is do the handler to delete the cookie.

//Filename: internal/handlers/auth.handlers.go

func (ah *AuthHandler) Logout(c echo.Context) error {
	cookie := http.Cookie{
		Name:     "user",
		Value:    "",
		Path:     "/",
		Secure:   true,
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
		Expires:  time.Now().Add(24 * time.Hour),
	}

	c.SetCookie(&cookie)

	return c.Redirect(http.StatusSeeOther, "/")
}

And it should works fine.

⚠️ **GitHub.com Fallback** ⚠️