Dwi Wahyudi

Senior Software Engineer (Ruby, Golang, Java)


In this article we’re going to continue our mini-commerce backend service, now it’s time to create the login service.

Overview

We need to make sure that our login service is secure and easy enough to follow. We’ve hashed our password in database, when a user wants to login to an account, we need to check such inputted password to match with account’s hashed password in database. We’ve already made that method before in external/extcommon/argon2.go.

For our restful API endpoints authentication, we’re going to use JWT token. Clients (web and mobile apps) will need to retain and use this token in order to authenticate with our mini-commerce API endpoints.

JWT Setup

First thing we need to do is to get the JWT library.

$ go get github.com/golang-jwt/jwt/v4

JWT asymmetric hashing requires a secret key for signing. We’re going to create some new configurations in app.toml for our JWT setups:

[jwtconfig]
issuer="mini-commerce"

login_secret="sample-login-secret"
login_expired_after_hours=96

We’re going to pass these values, down to external/extcommon/jwt.go below.

Now, let’s add some structs for reading these new configs in external/external.go:

    config struct {
        // .. skipped for brevity
		JWTConfig          JWTConfig          `toml:"jwtconfig"`
	}


	JWTConfig struct {
		LoginSecret            string `toml:"login_secret"`
		Issuer                 string `toml:"issuer"`
		LoginExpiredAfterHours int    `toml:"login_expired_after_hours"`
	}

We also going to add those fields for RealCommonService struct in external/extcommon/extcommon.go:

	RealCommonService struct {
		LoginSecret            string
		LoginExpiredAfterHours int
		JWTIssuer              string
	}

After adding those new fields both in config and RealCommonService structs, we can now create new external common library file, external/extcommon/jwt.go:

package extcommon

import (
	"go-mini-commerce/entity"
	"time"

	"github.com/golang-jwt/jwt/v4"
)

type (
	JWTService interface {
		JWTLoginBuild(account entity.Account) (string, error)
	}

	AccountClaim struct {
		AccountID int64 `json:"account_id"`
		jwt.RegisteredClaims
	}

	LoginClaim struct {
		AccountID int64 `json:"account_id"`
	}
)

func (rcs *RealCommonService) JWTLoginBuild(account entity.Account) (string, error) {
	claims := AccountClaim{
		account.ID,
		jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(rcs.LoginExpiredAfterHours) * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
			Issuer:    rcs.JWTIssuer,
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	secret := []byte(rcs.LoginSecret)
	signedToken, err := token.SignedString(secret)
	if err != nil {
		return "", err
	}

	return signedToken, nil
}

We’re creating JWTLoginBuild method that receive entity.Account parameter. In that method we use the LoginSecret string as the key for signing the login JWT tokens.

Inside the claims, there are some registered/reserved claim fields that we fill in, we also put account.ID to signify that such token belongs to specific account ID. We’re going to expose JWTService interface above to CommonService interface in external/extcommon/extcommon.go for our service code to use.

Since rcs *RealCommonService here now requires new 3 fields to be injected, we’re going to update extcommon injector code:

func InjectNewCommonService(
	loginSecret string,
	jwtIssuer string,
	loginExpiredAfterhHours int,
) CommonService {
	commonSvcOnce.Do(func() {
		commonSvc = &RealCommonService{
			LoginSecret:            loginSecret,
			JWTIssuer:              jwtIssuer,
			LoginExpiredAfterHours: loginExpiredAfterhHours,
		}
	})

	return commonSvc
}

And finally we update the default call to this injector method in external/inject.go:

    // .. skipped for brevity
	if commonSvc == nil {
		decidedCommonSvc = extcommon.InjectNewCommonService(
			cfg.JWTConfig.LoginSecret,
			cfg.JWTConfig.Issuer,
			cfg.JWTConfig.LoginExpiredAfterHours,
		)
	}
    // .. skipped for brevity

Update in Account Repo

We’ll start by adding new query to accountrepo package:

findByEmail = `SELECT * FROM account WHERE email = $1;`

Such query will receive email argument, in this case, it will be coming from user login form.

Next, let’s create a new file, repo/accountrepo/pg.go containing a new method to call that query:

package accountrepo

import (
	"context"
	"database/sql"
	"errors"
	"go-mini-commerce/entity"
)

func (a *accountPg) FindByEmail(ctx context.Context, email string) (entity.Account, error) {
	var account entity.Account

	err := a.db.Read.QueryRowContext(ctx, findByEmail, email).
		Scan(&account.ID, &account.Email, &account.PasswordHash,
			&account.Name, &account.CreatedAt, &account.EmailVerified)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return account, entity.ErrAccountNotFound
		}
		return account, err
	}

	return account, err
}

We need to add that new custom error entity.ErrAccountNotFound too into entity/error.go. Such error will be returned when no account found (indicated by sql.ErrNoRows).

    ErrAccountNotFound          = fmt.Errorf("account not found")

And we need to expose that method implementation to AccountRepo interface:

	AccountRepo interface {
		FindByEmail(ctx context.Context, email string) (entity.Account, error)
        // .. skipped for brevity

New Account Service

In order to connect that repo method above with login restful route, we’re going to create a new service file, service/accountservice/account_service.go:

package accountservice

import (
	"context"
	"go-mini-commerce/entity"
	"go-mini-commerce/external/extcommon"
	"go-mini-commerce/repo/accountrepo"
)

type (
	AccountService interface {
		Login(ctx context.Context, email, password string) (string, error)
	}

	accountService struct {
		commonSvc   extcommon.CommonService
		accountRepo accountrepo.AccountRepo
	}
)

func (a *accountService) Login(ctx context.Context, email, password string) (string, error) {
	account, err := a.accountRepo.FindByEmail(ctx, email)
	if err != nil {
		return "", err
	}

	passwordMatch, err := a.commonSvc.ComparePasswordAndHash(password, account.PasswordHash)
	if err != nil {
		return "", err
	}

	if !passwordMatch {
		return "", entity.ErrIncorrectPassword
	}

	token, err := a.commonSvc.JWTLoginBuild(account)
	if err != nil {
		return "", err
	}

	return token, nil
}

That Login method will do the followings:

  • First, we’re going to get account by email.
    • Remember that in above repo method, we specify that if no account found it will return entity.ErrAccountNotFound, this will go through and bubble up to the caller of this Login method.
  • We then match user’s inputted password (from login form) to password hash saved in database (during registration phase).
    • If ComparePasswordAndHash find out that inputted password is wrong (doesn’t match the saved password hash), we’re going to return entity.ErrIncorrectPassword, a new custom error, in entity/error.go.
    ErrIncorrectPassword        = fmt.Errorf("incorrect password")
    
  • If password match with the saved password hash, we’re going to build the JWT token with a call to JWTLoginBuild. We then return such token to the caller.

And like usual, in order for this service method to be called, we’re going to create a new injector (service/accountservice/inject.go) to be called by restful layer. Don’t forget to inject common service too.

package accountservice

import (
	"go-mini-commerce/infra"
	"go-mini-commerce/repo/accountrepo"
	"sync"
)

var (
	accountSvcOnce sync.Once
	accountSvc     AccountService
)

func InjectNewAccountService(infra infra.Infra) AccountService {
	accountSvcOnce.Do(func() {
		accountRepo := accountrepo.InjectNewAccountRepo(infra)
		accountSvc = &accountService{
			accountRepo: accountRepo,
			commonSvc:   infra.External().CommonSvc(),
		}
	})

	return accountSvc
}

New Account Restful Handler

Now, we’ve come to the restful handler layer, where we’re going to call Login service method above. We’re going to need the login form struct first, let’s place it in a newly restful handler package (restful/v1/accountrestful) together with its validation (in restful/v1/accountrestful/login_form.go). We’ll make sure, in such form, email and and password, both of them exist.

package accountrestful

import (
	"fmt"

	validation "github.com/go-ozzo/ozzo-validation"
	"github.com/go-ozzo/ozzo-validation/is"
)

type (
	HttpLoginForm struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
)

func (h *HttpLoginForm) Validate() error {
	err := validation.Validate(h.Email, validation.Required, is.Email)
	if err != nil {
		return fmt.Errorf("validation error: %w, %s", err, "invalid email")
	}

	err = validation.Validate(h.Password, validation.Required)
	if err != nil {
		return fmt.Errorf("validation error: %w, %s", err, "invalid password")
	}

	return nil
}

And here’s the restful handler method in a new file: restful/v1/accountrestful/account_restful.go:

package accountrestful

import (
	"encoding/json"
	"errors"
	"go-mini-commerce/entity"
	"go-mini-commerce/restful/httpcommon"
	"go-mini-commerce/service/accountservice"
	"net/http"
)

type (
	AccountRestful interface {
		Login(w http.ResponseWriter, r *http.Request)
	}

	accountRestful struct {
		accountService accountservice.AccountService
	}

	LoginResponse struct {
		Token string `db:"token"`
	}
)

func (acc *accountRestful) Login(w http.ResponseWriter, r *http.Request) {
	var form HttpLoginForm

	decoder := json.NewDecoder(r.Body)
	err := decoder.Decode(&form)
	if err != nil {
		httpcommon.SetResponse(w, http.StatusBadRequest, "bad request")
		return
	}

	err = form.Validate()
	if err != nil {
		httpcommon.SetResponse(w, http.StatusUnprocessableEntity, err.Error())
		return
	}

	token, err := acc.accountService.Login(r.Context(), form.Email, form.Password)
	if err != nil {
		if errors.Is(err, entity.ErrAccountNotFound) {
			httpcommon.SetResponse(w, http.StatusNotFound, "email not found")
			return
		}

		if errors.Is(err, entity.ErrIncorrectPassword) {
			httpcommon.SetResponse(w, http.StatusUnauthorized, "invalid email or password")
			return
		}
		httpcommon.SetResponse(w, http.StatusInternalServerError, "unknown error")
		return
	}

	httpcommon.SetJSONResponse(w, http.StatusOK, LoginResponse{Token: token})
}

Nothing new here, except 3 notable things:

  • When service throws entity.ErrAccountNotFound, we return 404 status code, in our case, this error is bubbled from accountrepo when such email cannot be found in our database.
  • When service throws entity.ErrIncorrectPassword we return 401 status code.
  • We create a new httpmethod function, SetJSONResponse:
func SetJSONResponse(w http.ResponseWriter, status int, message any) {
	w.Header().Add("Content-Type", "application/json")
	w.WriteHeader(status)

	messageResponse, _ := json.Marshal(message)

	fmt.Fprint(w, string(messageResponse))
}

We’re ignoring the err returned by json.Marshal above, we can safely do so, as long as we don’t send it channel or function data types.

And here’s the injector (restful/v1/accountrestful/inject.go):

import (
	"go-mini-commerce/infra"
	"go-mini-commerce/service/accountservice"
	"sync"
)

var (
	accountRestfulOnce sync.Once
	accountRestfulVar  AccountRestful
)

func InjectNewAccountRestful(infra infra.Infra) AccountRestful {
	accountRestfulOnce.Do(func() {
		accountService := accountservice.InjectNewAccountService(infra)
		accountRestfulVar = &accountRestful{
			accountService: accountService,
		}
	})

	return accountRestfulVar
}

Let’s create the restful route for this restful handler

    // .. skipped for brevity
    accountRestful      = accountrestful.InjectNewAccountRestful(infra)
    // .. skipped for brevity
	r.Route("/v1", func(v1Route chi.Router) {
		v1Route.Route("/accounts", func(accountRoute chi.Router) {
			accountRoute.Route("/registration", func(registrationRoute chi.Router) {
				registrationRoute.Post("/", registrationRestful.RegisterUser)
				registrationRoute.Get("/email_confirmation", registrationRestful.AccountVerifyEmail)
			})
			accountRoute.Post("/login", accountRestful.Login) // new route
		})
	})

Testing Our API

When successfully logged in, the clients will get freshly made JWT token:

When inputting the wrong password, we’ll expect our API to return 401 status:

And when we try to login with unregistered email, our API will return 404 status: