Authentication
Authentication in an API server is typically needed when you want to control access to certain resources or functionalities.
In other words, if you want to restrict access to certain API endpoints or data to users with special roles (e.g., administrator, moderator, or premium user), authentication is required.
Traditional User Flow
Update code
Add User
domain entity in domain/model/user.go:
package model
import "time"
// User represents an app user
type User struct {
ID uint `json:"id,omitempty"`
Email string `json:"email,omitempty"`
Password string `json:"password,omitempty"`
Salt string `json:"salt,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Declare its business capabilities in domain/gateway/user_manager.go:
package gateway
import (
"context"
"literank.com/rest-books/domain/model"
)
// UserManager manages users
type UserManager interface {
CreateUser(ctx context.Context, u *model.User) (uint, error)
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
}
Add implementations in infrastructure/database/mysql.go:
@@ -23,7 +23,7 @@ func NewMySQLPersistence(dsn string, pageSize int) (*MySQLPersistence, error) {
return nil, err
}
// Auto Migrate the data structs
- db.AutoMigrate(&model.Book{})
+ db.AutoMigrate(&model.Book{}, &model.User{})
return &MySQLPersistence{db, pageSize}, nil
}
@@ -67,3 +67,18 @@ func (s *MySQLPersistence) GetBooks(ctx context.Context, offset int, keyword str
}
return books, nil
}
+
+func (s *MySQLPersistence) CreateUser(ctx context.Context, u *model.User) (uint, error) {
+ if err := s.db.WithContext(ctx).Create(u).Error; err != nil {
+ return 0, err
+ }
+ return u.ID, nil
+}
+
+func (s *MySQLPersistence) GetUserByEmail(ctx context.Context, email string) (*model.User, error) {
+ var u model.User
+ if err := s.db.WithContext(ctx).Where("email = ?", email).First(&u).Error; err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
Do the sign-up and sign-in preparation work in application/executor/user_operator.go:
package executor
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"math/rand"
"time"
"literank.com/rest-books/application/dto"
"literank.com/rest-books/domain/gateway"
"literank.com/rest-books/domain/model"
)
const (
saltLen = 4
errEmptyEmail = "empty email"
errEmptyPassword = "empty password"
)
type UserOperator struct {
userManager gateway.UserManager
}
func NewUserOperator(u gateway.UserManager) *UserOperator {
return &UserOperator{userManager: u}
}
// CreateUser creates a new user
func (u *UserOperator) CreateUser(ctx context.Context, uc *dto.UserCredential) (*dto.User, error) {
if uc.Email == "" {
return nil, errors.New(errEmptyEmail)
}
if uc.Password == "" {
return nil, errors.New(errEmptyPassword)
}
salt := randomString(saltLen)
user := &model.User{
Email: uc.Email,
Password: sha1Hash(uc.Password + salt),
Salt: salt,
}
uid, err := u.userManager.CreateUser(ctx, user)
if err != nil {
return nil, err
}
return &dto.User{
ID: uid,
Email: uc.Email,
}, nil
}
// SignIn signs an user in
func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto.User, error) {
if email == "" {
return nil, errors.New(errEmptyEmail)
}
if password == "" {
return nil, errors.New(errEmptyPassword)
}
user, err := u.userManager.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
passwordHash := sha1Hash(password + user.Salt)
if user.Password != passwordHash {
return nil, errors.New("wrong password")
}
return &dto.User{
ID: user.ID,
Email: user.Email,
}, nil
}
func randomString(length int) string {
source := rand.NewSource(time.Now().UnixNano())
random := rand.New(source)
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
for i := range result {
result[i] = charset[random.Intn(len(charset))]
}
return string(result)
}
func sha1Hash(input string) string {
h := sha1.New()
h.Write([]byte(input))
hashBytes := h.Sum(nil)
hashString := hex.EncodeToString(hashBytes)
return hashString
}
func randomString
and sha1Hash
are helper functions for salt generation and password hashing.
Why do you need a salt?
Without a salt, an attacker could precompute hashes for common passwords and store them in a table (known as a rainbow table). If the database is compromised, the attacker can then compare the hashed passwords against the precomputed hashes to quickly identify passwords. Adding a salt to each password ensures that even if two users have the same password, their hashed passwords will be different due to the unique salt.
Even if a user chooses a weak password, such as a common dictionary word, the salt ensures that the resulting hash is unique. Without a salt, all instances of the same password would hash to the same value, making them vulnerable to attacks.
Add DTOs in application/dto/user.go:
package dto
// UserCredential represents the user's sign-in email and password
type UserCredential struct {
Email string `json:"email,omitempty"`
Password string `json:"password,omitempty"`
}
// User is used as result of a successful sign-in
type User struct {
ID uint `json:"id,omitempty"`
Email string `json:"email,omitempty"`
}
DTO stands for Data Transfer Object.
Often, the internal representation of data within your application might differ from what you want to expose through your API. DTOs allow you to transform your internal data structures into a format that's more suitable for the API consumers. This helps in decoupling the internal representation from the external one, allowing for better flexibility and abstraction.
Wire in new dependencies in application/wire_helper.go:
@@ -31,6 +31,10 @@ func (w *WireHelper) BookManager() gateway.BookManager {
return w.sqlPersistence
}
+func (w *WireHelper) UserManager() gateway.UserManager {
+ return w.sqlPersistence
+}
+
func (w *WireHelper) ReviewManager() gateway.ReviewManager {
return w.noSQLPersistence
}
Finnally, add routes in adaptor/router.go:
@@ -22,12 +22,14 @@ const (
type RestHandler struct {
bookOperator *executor.BookOperator
reviewOperator *executor.ReviewOperator
+ userOperator *executor.UserOperator
}
func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
rest := &RestHandler{
bookOperator: executor.NewBookOperator(wireHelper.BookManager(), wireHelper.CacheHelper()),
reviewOperator: executor.NewReviewOperator(wireHelper.ReviewManager()),
+ userOperator: executor.NewUserOperator(wireHelper.UserManager()),
}
// Create a new Gin router
r := gin.Default()
@@ -49,6 +51,10 @@ func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
r.POST("/reviews", rest.createReview)
r.PUT("/reviews/:id", rest.updateReview)
r.DELETE("/reviews/:id", rest.deleteReview)
+
+ userGroup := r.Group("/users")
+ userGroup.POST("", rest.userSignUp)
+ userGroup.POST("/sign-in", rest.userSignIn)
return r, nil
}
@@ -220,3 +226,33 @@ func (r *RestHandler) deleteReview(c *gin.Context) {
}
c.JSON(http.StatusNoContent, nil)
}
+
+func (r *RestHandler) userSignUp(c *gin.Context) {
+ var ucBody dto.UserCredential
+ if err := c.ShouldBindJSON(&ucBody); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ u, err := r.userOperator.CreateUser(c, &ucBody)
+ if err != nil {
+ fmt.Printf("Failed to create user: %v\n", err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "failed to sign up"})
+ return
+ }
+ c.JSON(http.StatusCreated, u)
+}
+
+func (r *RestHandler) userSignIn(c *gin.Context) {
+ var m dto.UserCredential
+ if err := c.ShouldBindJSON(&m); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ u, err := r.userOperator.SignIn(c, m.Email, m.Password)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, u)
+}
Try with curl
User sign up
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:8080/users
Result:
{"id":2,"email":"test-user@example.com"}
If you check out the records in the database, you will find something like this:
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
| id | email | password | salt | is_admin | created_at | updated_at |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
| 2 | test-user@example.com | f9264ef99598a1be989cfca6a692cb677dbe7ac4 | 4Fnd | 0 | 2024-02-27 11:44:05.350 | 2024-02-27 11:44:05.350 |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
User sign in successfully
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:8080/users/sign-in
Result:
{"id":2,"email":"test-user@example.com"}
User sign in with an incorrect password
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "wrong-pass"}' http://localhost:8080/users/sign-in
Result:
{"error":"wrong password"}
Note:
It's good practice to use a uniform JSON response format for both successful and failed responses.
e.g.{"code":0, "message":"ok", "data": {"id":2,"email":"test-user@example.com"}} {"code":1001, "message":"wrong password", "data": null}
Use JWT (JSON Web Token)
JWT, or JSON Web Token, is a compact, URL-safe means of representing claims to be transferred between two parties. It's often used for authentication and authorization in web applications and APIs.
Install JWT dependency
go get -u github.com/golang-jwt/jwt/v5
Update code
Add UserPermission
enums in domain/model/user.go:
@@ -2,6 +2,17 @@ package model
import "time"
+// UserPermission represents different levels of user permissions.
+type UserPermission uint8
+
+// UserPermission levels
+const (
+ PermNone UserPermission = iota
+ PermUser
+ PermAuthor
+ PermAdmin
+)
+
// User represents an app user
type User struct {
ID uint `json:"id,omitempty"`
Declare its business capabilities in domain/gateway/user_manager.go:
@@ -11,3 +11,9 @@ type UserManager interface {
CreateUser(ctx context.Context, u *model.User) (uint, error)
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
}
+
+// PermissionManager manage user permissions by tokens
+type PermissionManager interface {
+ GenerateToken(userID uint, email string, perm model.UserPermission) (string, error)
+ HasPermission(tokenResult string, perm model.UserPermission) (bool, error)
+}
Implement these 2 methods in infrastructure/token/jwt.go:
package token
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"literank.com/rest-books/domain/model"
)
const (
errInvalidToken = "invalid token"
errFailToConvert = "failed to convert token type"
)
// Keeper manages user tokens.
type Keeper struct {
secretKey []byte
expireHours uint
}
// UserClaims includes user info.
type UserClaims struct {
UserID uint `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Permission model.UserPermission `json:"permission,omitempty"`
jwt.RegisteredClaims
}
// NewTokenKeeper constructs a new JWT token keeper
func NewTokenKeeper(secretKey string, expireInHours uint) *Keeper {
return &Keeper{[]byte(secretKey), expireInHours}
}
// GenerateToken generates a new JWT token.
func (t *Keeper) GenerateToken(userID uint, email string, perm model.UserPermission) (string, error) {
claims := UserClaims{
userID, email, perm,
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(t.expireHours) * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenResult, err := token.SignedString(t.secretKey)
if err != nil {
return "", err
}
return tokenResult, nil
}
// ExtractToken extracts the token from the signed string.
func (t *Keeper) ExtractToken(tokenResult string) (*UserClaims, error) {
token, err := jwt.ParseWithClaims(tokenResult, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
return t.secretKey, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New(errInvalidToken)
}
claims, ok := token.Claims.(*UserClaims)
if !ok {
return nil, errors.New(errFailToConvert)
}
return claims, nil
}
// HasPermission checks if user has the given permission.
func (t *Keeper) HasPermission(tokenResult string, perm model.UserPermission) (bool, error) {
claims, err := t.ExtractToken(tokenResult)
if err != nil {
return false, err
}
return claims.Permission >= perm, nil
}
Add config items for Tokens in infrastructure/config/config.go:
@@ -21,8 +21,10 @@ type DBConfig struct {
}
type ApplicationConfig struct {
- Port int `json:"port" yaml:"port"`
- PageSize int `json:"page_size" yaml:"page_size"`
+ Port int `json:"port" yaml:"port"`
+ PageSize int `json:"page_size" yaml:"page_size"`
+ TokenSecret string `json:"token_secret" yaml:"token_secret"`
+ TokenHours int `json:"token_hours" yaml:"token_hours"`
}
type CacheConfig struct {
Put in setting values in config.yml:
@@ -1,6 +1,8 @@
app:
port: 8080
page_size: 5
+ token_secret: "I_Love_LiteRank"
+ token_hours: 72
db:
file_name: "test.db"
Generate tokens and return them in application/executor/user_operator.go:
@@ -21,10 +21,11 @@ const (
type UserOperator struct {
userManager gateway.UserManager
+ permManager gateway.PermissionManager
}
-func NewUserOperator(u gateway.UserManager) *UserOperator {
- return &UserOperator{userManager: u}
+func NewUserOperator(u gateway.UserManager, p gateway.PermissionManager) *UserOperator {
+ return &UserOperator{userManager: u, permManager: p}
}
// CreateUser creates a new user
@@ -52,7 +53,7 @@ func (u *UserOperator) CreateUser(ctx context.Context, uc *dto.UserCredential) (
}
// SignIn signs an user in
-func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto.User, error) {
+func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto.UserToken, error) {
if email == "" {
return nil, errors.New(errEmptyEmail)
}
@@ -67,13 +68,33 @@ func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto
if user.Password != passwordHash {
return nil, errors.New("wrong password")
}
+ token, err := u.permManager.GenerateToken(user.ID, user.Email, calcPerm(user.IsAdmin))
+ if err != nil {
+ return nil, err
+ }
- return &dto.User{
- ID: user.ID,
- Email: user.Email,
+ return &dto.UserToken{
+ User: dto.User{
+ ID: user.ID,
+ Email: user.Email,
+ },
+ Token: token,
}, nil
}
+// HasPermission checks if user has the given permission.
+func (u *UserOperator) HasPermission(tokenResult string, perm model.UserPermission) (bool, error) {
+ return u.permManager.HasPermission(tokenResult, perm)
+}
+
+func calcPerm(isAdmin bool) model.UserPermission {
+ perm := model.PermUser
+ if isAdmin {
+ perm = model.PermAdmin
+ }
+ return perm
+}
+
func randomString(length int) string {
source := rand.NewSource(time.Now().UnixNano())
random := rand.New(source)
Add DTOs in application/dto/user.go:
@@ -11,3 +11,9 @@ type User struct {
ID uint `json:"id,omitempty"`
Email string `json:"email,omitempty"`
}
+
+// UserToken is a combination of the User struct and the token field
+type UserToken struct {
+ User User `json:"user,omitempty"`
+ Token string `json:"token,omitempty"`
+}
Tune application/wire_helper.go:
@@ -5,6 +5,7 @@ import (
"literank.com/rest-books/infrastructure/cache"
"literank.com/rest-books/infrastructure/config"
"literank.com/rest-books/infrastructure/database"
+ "literank.com/rest-books/infrastructure/token"
)
// WireHelper is the helper for dependency injection
@@ -12,6 +13,7 @@ type WireHelper struct {
sqlPersistence *database.MySQLPersistence
noSQLPersistence *database.MongoPersistence
kvStore *cache.RedisCache
+ tokenKeeper *token.Keeper
}
func NewWireHelper(c *config.Config) (*WireHelper, error) {
@@ -24,7 +26,10 @@ func NewWireHelper(c *config.Config) (*WireHelper, error) {
return nil, err
}
kv := cache.NewRedisCache(&c.Cache)
- return &WireHelper{sqlPersistence: db, noSQLPersistence: mdb, kvStore: kv}, nil
+ tk := token.NewTokenKeeper(c.App.TokenSecret, uint(c.App.TokenHours))
+ return &WireHelper{
+ sqlPersistence: db, noSQLPersistence: mdb,
+ kvStore: kv, tokenKeeper: tk}, nil
}
func (w *WireHelper) BookManager() gateway.BookManager {
@@ -35,6 +40,10 @@ func (w *WireHelper) UserManager() gateway.UserManager {
return w.sqlPersistence
}
+func (w *WireHelper) PermManager() gateway.PermissionManager {
+ return w.tokenKeeper
+}
+
func (w *WireHelper) ReviewManager() gateway.ReviewManager {
return w.noSQLPersistence
}
Add PermCheck
middleware into routes for Create/Update/Delete operations in adaptor/router.go:
@@ -25,12 +25,16 @@ type RestHandler struct {
userOperator *executor.UserOperator
}
-func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
- rest := &RestHandler{
+func newRestHandler(wireHelper *application.WireHelper) *RestHandler {
+ return &RestHandler{
bookOperator: executor.NewBookOperator(wireHelper.BookManager(), wireHelper.CacheHelper()),
reviewOperator: executor.NewReviewOperator(wireHelper.ReviewManager()),
- userOperator: executor.NewUserOperator(wireHelper.UserManager()),
+ userOperator: executor.NewUserOperator(wireHelper.UserManager(), wireHelper.PermManager()),
}
+}
+
+func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
+ rest := newRestHandler(wireHelper)
// Create a new Gin router
r := gin.Default()
@@ -43,9 +47,9 @@ func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
})
r.GET("/books", rest.getBooks)
r.GET("/books/:id", rest.getBook)
- r.POST("/books", rest.createBook)
- r.PUT("/books/:id", rest.updateBook)
- r.DELETE("/books/:id", rest.deleteBook)
+ r.POST("/books", rest.PermCheck(model.PermAuthor), rest.createBook)
+ r.PUT("/books/:id", rest.PermCheck(model.PermAuthor), rest.updateBook)
+ r.DELETE("/books/:id", rest.PermCheck(model.PermAuthor), rest.deleteBook)
r.GET("/books/:id/reviews", rest.getReviewsOfBook)
r.GET("/reviews/:id", rest.getReview)
r.POST("/reviews", rest.createReview)
PermCheck
is implemented in adaptor/middleware.go:
package adaptor
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"literank.com/rest-books/domain/model"
)
const tokenPrefix = "Bearer "
// PermCheck checks user permission
func (r *RestHandler) PermCheck(allowPerm model.UserPermission) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token is required"})
c.Abort()
return
}
token := strings.Replace(authHeader, tokenPrefix, "", 1)
hasPerm, err := r.userOperator.HasPermission(token, allowPerm)
message := "Unauthorized"
if err != nil {
message = err.Error()
}
if !hasPerm {
c.JSON(http.StatusUnauthorized, gin.H{"error": message})
c.Abort()
return
}
c.Next()
}
}
Those are all the changes you need to utilize JWT in your api server. Let's try it out again!
Try with curl
User sign in and get a Token
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:8080/users/sign-in
Result:
{
"user": { "id": 2, "email": "test-user@example.com" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjoxLCJleHAiOjE3MDkyNzA1MzV9.eJp5Yr9YZMxHFs5eId78bEJ6draO178jysquZ2VV9v8"
}
Put the token into Debugger on https://jwt.io/, then you can check the payload and verify the signature of your token.
Create a new book with a valid and proper Token
curl -X POST \
http://localhost:8080/books \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjozLCJleHAiOjE3MDkyNzExMjl9.ybMnzuP0v__JVviWDKxeKaeTqpAn6wQL9CXfiNj7cM4' \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
Successful result:
{
"id": 14,
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100,
"created_at": "2024-02-27T13:32:40.5+08:00",
"updated_at": "2024-02-27T13:32:40.5+08:00"
}
Create a new book with a Token, but it doesn't have proper permissions
curl -X POST \
http://localhost:8080/books \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjoxLCJleHAiOjE3MDkyNzA1MzV9.eJp5Yr9YZMxHFs5eId78bEJ6draO178jysquZ2VV9v8' \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
Result:
{"error":"Unauthorized"}
Create a new book without a Token
curl -X POST \
http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
Result:
{"error":"token is required"}
Create a new book with a fake Token
curl -X POST \
http://localhost:8080/books \
-H 'Authorization: Bearer FAKE_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
Result:
{"error":"token is malformed: token contains an invalid number of segments"}
Perfect! 🎉
Now, some of your api endpoints are protected by the authentication mechanism.