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 thisLogin
method.
- Remember that in above repo method, we specify that if no account found it will return
- 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 returnentity.ErrIncorrectPassword
, a new custom error, inentity/error.go
.
ErrIncorrectPassword = fmt.Errorf("incorrect password")
- If
- 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 return404
status code, in our case, this error is bubbled fromaccountrepo
when such email cannot be found in our database. - When service throws
entity.ErrIncorrectPassword
we return401
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: