HomeArtTechHackBlockchain

บันทึก การทำ Go fiber JWT Accesstoken & Refreshtoken ลงบน Redis

By Khomkrid Lerdprasert
Published in Technology
June 10, 2021
1 min read
บันทึก การทำ Go fiber JWT Accesstoken & Refreshtoken ลงบน Redis

จดบันทึก การทำ Go fiber JWT Accesstoken & Refreshtoken ลงบน Redis แบบง่ายๆ

โดย Routing เราจะมีตามนี้ โดยใช้ pluging rest client บน vscode https://marketplace.visualstudio.com/items?itemName=humao.rest-client

@hostname = localhost
@port = 8181
@host = {{hostname}}:{{port}}
@contentType = application/json
###
GET http://{{host}}/
Content-Type: {{contentType}}
###
POST http://{{host}}/login
Content-Type: {{contentType}}
{
"username":"aofiee",
"password":"password"
}
###
GET http://{{host}}/profile
Authorization: Bearer xxxx
###
DELETE http://{{host}}/logout
Authorization: Bearer xxxx
###
POST http://{{host}}/refresh
Content-Type: application/x-www-form-urlencoded
refresh_token=xxxx
###
POST http://{{host}}/refresh
Content-Type: {{contentType}}
{"refresh_token":"xxxx"}

โดย Project Layout ที่ผมวางโครงสร้างไว้จะเป็นแบบนี้

tree -I 'diablos|mysql|performance_schema|sys|aria_*|ib*|multi*|*.js|*.json|*.html|*.svg|*.png|*.ico|*.txt|web|WPMultiPostContent.code-workspace|*.bak|tmp' -L 3
.
├── Readme.md
├── api
│   ├── Readme.http
│   ├── cmd
│   │   └── main.go
│   ├── go-dev.Dockerfile
│   ├── go.mod
│   ├── go.sum
│   ├── models
│   │   └── Model.go
│   ├── routes
│   │   └── LoginRoute.go
│   └── types
│   └── LoginType.go
├── database
│   ├── ddl_recovery-backup.log
│   └── ddl_recovery.log
├── docker-compose.yml
├── queue
│   ├── data
│   │   └── mnesia
│   └── log
│   └── log
└── redis
├── data
└── redis.conf
13 directories, 13 files

โดย folder api ผมจะใช้เก็บ file ต่างๆที่เขียนด้วย golang และ cmd จะใช้เก็บไฟล์ที่จะถูก exec ขึ้นมา

ในส่วนของ folder database ผมจะทำการ map volumes เข้าไปยัง container เพื่อเก็บ database mariadb folder queue ผมใช้ในการ map volumes rabbitmq เพื่อที่จะใช้ในการทำงานขั้นตอนต่อไป และ redis ผมได้ทำการ map volumes data เข้าไปยัง container และ ทำการ map file config เข้าไปด้วยจะได้ config ได้ง่ายๆจากตรงนี้

สุดท้าย docker-compose.yml จะเป็นแบบนี้

version: '3.8'
services:
database:
image: mariadb:10.6
container_name: db
ports:
- 3308:3306
volumes:
- './database:/var/lib/mysql'
environment:
MYSQL_ROOT_PASSWORD: \t=H9fB_uy/2A\ax
MYSQL_DATABASE: diablos
networks:
- diablos
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: pma
ports:
- 8000:80
environment:
PMA_PASSWORD: \t=H9fB_uy/2A\ax
PMA_USER: root
PMA_HOSTS: database
networks:
- diablos
rabbitmq:
image: rabbitmq:management-alpine
container_name: rabbitmq
ports:
- 5672:5672
- 15672:15672
volumes:
- './queue/data:/var/lib/rabbitmq'
- './queue/log:/var/log/rabbitmq'
networks:
- diablos
redis:
image: 'redis:latest'
container_name: redis
hostname: redis
command: ['redis-server', '--include', '/usr/local/etc/redis/redis.conf']
ports:
- '6379:6379'
volumes:
- './redis/data:/var/lib/redis'
- './redis/redis.conf:/usr/local/etc/redis/redis.conf'
environment:
- REDIS_REPLICATION_MODE=master
networks:
- diablos
api:
# image: golang:1.16-alpine
build:
context: .
dockerfile: ./api/go-dev.Dockerfile
container_name: api
volumes:
- './api:/app'
working_dir: /app/cmd
ports:
- 8181:8181
# command: go run main.go
links:
- database
- rabbitmq
- redis
networks:
- diablos
networks:
diablos:
driver: bridge

โดยเราจะใช้ Live reload บน golang ด้วย AIR

FROM golang:1.16-alpine
RUN mkdir /app
ADD . /app/
WORKDIR /app/cmd
RUN go get -v github.com/cosmtrek/air
ENTRYPOINT ["air"]

เมื่อเราเตรียม config เสร็จแล้วก็ docker-compose up -d ขึ้นมา

ผมใช้ Medis Medis - GUI for Redis ในการทดสอบ login เข้าไปยัง redis server ดูก่อน

Redis Client
Redis Client

จากนั้นใน main.go เราจะมาทำการ register routing ลงใน go fiber

func main() {
app.Use(cors.New(cors.Config{
AllowOrigins: os.Getenv("ALLOW_ORIGINS"),
AllowHeaders: "Origin, Content-Type, Accept",
}))
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Hello, World!")
})
app.Post("/login", routes.Auth)
app.Post("/refresh", routes.RefreshToken)
app.Use(routes.AuthorizationRequired())
app.Get("/profile", routes.Profile)
app.Delete("/logout", routes.Logout)
err := app.Listen(":" + os.Getenv("APP_PORT"))
if err != nil {
panic(err)
}
}

หลังจากนั้นเราจะมาเขียน code ในส่วนการทำงานของ login routing ในไฟล์ LoginRoute.go โดยเราจะทำการอ่าน env มาสร้าง dns ให้ rabbitmq และ redis กันไว้ก่อน

func init() {
err := godotenv.Load(".env")
if err != nil {
panic("Error loading .env file")
}
rbqDNS = "amqp://" + os.Getenv("RB_USER") + ":" + os.Getenv("RB_PASSWORD") + "@" + os.Getenv("RB_HOST") + ":" + os.Getenv("RB_PORT") + "/"
rdAddr = os.Getenv("REDIS_HOST") + ":" + os.Getenv("REDIS_PORT")
rdConn = redis.NewClient(&redis.Options{
Addr: rdAddr,
Password: os.Getenv("REDIS_PASSWORD"),
// DB: 0,
})
}

จากนั้นเรามาสร้าง handler สำหรับ routing login จะทำการ hard code username password ลงไปก่อน ยังไม่อยากไป focus ในส่วนของ database

func Auth(c *fiber.Ctx) error {
var l MsgLogin
uid := "144479bd-fcdc-4c9f-b116-f2a08807a4c3" //utils.UUID()
err := c.BodyParser(&l)
if err != nil {
return failOnError(c, err, "cannot parse json", fiber.StatusBadRequest)
}
if l.Username != "aofiee" || l.Password != "password" {
return failOnError(c, err, "Bad Credentials", fiber.StatusUnauthorized)
}
return nil
}

จากนั้นเราจะมาทำการ create token ขี้นมา โดยจะ mock ค่า context ที่แนบไปกับ token ขึ้นมาก่อนเหมือนเดิม และทำการกำหนดวันหมดอายุของ token ทั้ง access และ refresh token

func createToken(uid string) (*MsgTokenDetail, error) {
tokenDetail := &MsgTokenDetail{}
location, _ := time.LoadLocation("Asia/Bangkok")
at := time.Now().In(location).Add(time.Minute * 15).Unix()
rt := time.Now().In(location).Add(time.Hour * 24 * 7).Unix()
tokenDetail.AccessTokenExp = at
tokenDetail.RefreshTokenExp = rt
tokenDetail.AccessUUid = utils.UUIDv4()
tokenDetail.RefreshUUid = utils.UUIDv4()
var err error
/////mock data/////
ctAccess := MsgJWTContext{}
ctAccess.User = "[email protected]"
ctAccess.DisplayName = "Khomkrid L."
roles := []string{
"admin",
"report",
}
ctAccess.Roles = roles
tokenDetail.Context = ctAccess
/////mock data/////
tokenDetail.Token.AccessToken, err = generateTokenBy(uid, tokenDetail.AccessUUid, ctAccess, os.Getenv("ACCESS_TOKEN_SECRET"), tokenDetail.AccessTokenExp)
if err != nil {
return tokenDetail, err
}
tokenDetail.Token.RefreshToken, err = generateTokenBy(uid, tokenDetail.RefreshUUid, nil, os.Getenv("REFRESH_TOKEN_SECRET"), tokenDetail.RefreshTokenExp)
if err != nil {
return tokenDetail, err
}
return tokenDetail, nil
}

ต่อไปเราจะทำการ generateTokenBy ออกมา

func generateTokenBy(uid string, rdKey string, ctx interface{}, signed string, expire int64) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
location, _ := time.LoadLocation("Asia/Bangkok")
claims["iss"] = os.Getenv("APP_NAME")
claims["sub"] = uid
claims["exp"] = expire
claims["iat"] = time.Now().In(location).Unix()
claims["context"] = ctx
if ctx != nil {
claims["access_uuid"] = rdKey
} else {
claims["refresh_uuid"] = rdKey
}
t, err := token.SignedString([]byte(signed))
if err != nil {
return "", err
}
return t, nil
}

ต่อไปเราจะทำการเก็บ token udid ของ access token และ refresh token ลงไปที่ redis ด้วยคำสั่ง rdConn.Set(ctx, t.AccessUUid, uid, atExp.Sub(now))

func storeJWTAuthToRedis(c *fiber.Ctx, uid string, t *MsgTokenDetail) error {
var err error
var ctx = context.Background()
location, _ := time.LoadLocation("Asia/Bangkok")
atExp := time.Unix(t.AccessTokenExp, 0).In(location)
rtExp := time.Unix(t.RefreshTokenExp, 0).In(location)
now := time.Now().In(location)
err = rdConn.Set(ctx, t.AccessUUid, uid, atExp.Sub(now)).Err()
if err != nil {
return err
}
err = rdConn.Set(ctx, t.RefreshUUid, uid, rtExp.Sub(now)).Err()
if err != nil {
return err
}
return nil
}

จากนั้นเราค่อยมาจัดการเพิ่มส่วนการสร้าง token และการบันทึก token เข้าไปใน Auth func อีกที ก็จะได้แบบนี้

func Auth(c *fiber.Ctx) error {
var l MsgLogin
uid := "144479bd-fcdc-4c9f-b116-f2a08807a4c3" //utils.UUID()
err := c.BodyParser(&l)
if err != nil {
return failOnError(c, err, "cannot parse json", fiber.StatusBadRequest)
}
if l.Username != "aofiee" || l.Password != "password" {
return failOnError(c, err, "Bad Credentials", fiber.StatusUnauthorized)
}
t, err := createToken(uid)
if err != nil {
return failOnError(c, err, "StatusForbidden", fiber.StatusForbidden)
}
err = storeJWTAuthToRedis(c, uid, t)
if err != nil {
return failOnError(c, err, "StatusBadRequest", fiber.StatusBadRequest)
}
c.Status(fiber.StatusOK).JSON(fiber.Map{
"access_token": t.Token.AccessToken,
"refresh_token": t.Token.RefreshToken,
})
return nil
}

ต่อไป เราจะมากำหนด middleware เพื่อรับ token ผ่าน header:Authorization Bearer

func AuthorizationRequired() fiber.Handler {
return jwtware.New(jwtware.Config{
SuccessHandler: AuthSuccess,
ErrorHandler: AuthError,
SigningKey: []byte(os.Getenv("ACCESS_TOKEN_SECRET")),
SigningMethod: "HS256",
TokenLookup: "header:Authorization",
AuthScheme: "Bearer",
})
}

จากนั้นเราจะมาทำ routing เพื่อทดสอบ /profile กัน ด้วย ก็คือ ถ้า AuthorizationRequired middleware ตรวจสอบ token แล้วว่าผ่าน ตรงกับ key ที่ signed มา จะ next มาที่ Profile Handler นี่ต่อ โดย Profile Handler จะทำการ ดึง claims “access_uuid” ออกมา เพื่อส่งไปถามที่ Redis ว่ามี access_uuid นี้อยู่หรือเปล่า ถ้ายังมีอยู่ ให้เข้าไปดึง Profile ของ User ได้ เพราะ Token นี้ยังไม่ถูก revoke ออก

func Profile(c *fiber.Ctx) error {
user := c.Locals("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
accessUUid := claims["access_uuid"].(string)
context := claims["context"]
uid, err := FetchAuth(accessUUid)
if err != nil {
return failOnError(c, err, "StatusUnauthorized", fiber.StatusUnauthorized)
}
c.Status(fiber.StatusOK).JSON(fiber.Map{
"sub": uid,
"context": context,
})
return nil
}
func FetchAuth(accessUUid string) (string, error) {
var ctx = context.Background()
uid, err := rdConn.Get(ctx, accessUUid).Result()
if err != nil {
return "", err
}
return uid, nil
}

จากนั้นเราจะมาทำ Routing Logout กันต่อ เพื่อให้ทุกครั้งที่ logout ให้ระบบ ไป delete access_uuid ออกจาก redis ด้วย เพื่อที่ถ้าใช้ token เดิมที่ไม่หมดอายุ จะได้เข้าไปขอข้อมูลไม่ได้อีกต่อไป

func Logout(c *fiber.Ctx) error {
user := c.Locals("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
accessUUid := claims["access_uuid"].(string)
var err error
_, err = deleteAuthFromRedis(c, accessUUid)
if err != nil {
return failOnError(c, err, "StatusUnauthorized", fiber.StatusUnauthorized)
}
c.Status(fiber.StatusOK).JSON(fiber.Map{
"msg": "Successfully logged out. Message : ",
"error": nil,
})
return nil
}
func deleteAuthFromRedis(c *fiber.Ctx, uid string) (string, error) {
var ctx = context.Background()
deleted, err := rdConn.GetDel(ctx, uid).Result()
if err != nil {
return "", err
}
return deleted, nil
}

ถ้าหากว่าเรามี Refresh token และเรายังอยากได้ข้อมูล Profile จาก User อยู่ โดยที่ Token หมดอายุไปแล้ว เราจะมาทำการสร้าง Refresh Handler ขึ้นมาเพื่อที่จะนำไปร้องขอ Token ผ่านจาก Redis Server อีกที โดยขั้นตอนนี้เราจะทำการสร้าง func VerifyToken ขึ้นมาเพื่อรับ token ที่ถูก POST เข้ามาผ่าน /refresh routing เข้ามาที่ RefreshToken Handler

หลังจาก VerifyToken แล้วว่าผ่าน ระบบจะถอดค่า claims “refresh_uuid” ออกมาจาก token เพื่อนำไปร้องขอจาก Redis ว่าหมดอายุหรือยังนะ Refreshtoken อันนี้ ถ้ายังมีอยู่ เราจะทำการ createToken ทั้ง refresh และ access token ขึ้นมาใหม่ และทำการ บันทึก Pair token ลงไปบน Redis ใหม่หลังจากนั้นจึงจะทำการส่ง Pair Token ชุดใหม่ส่งกลับไปให้กับ user

func VerifyToken(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("REFRESH_TOKEN_SECRET")), nil
}
func RefreshToken(c *fiber.Ctx) error {
var rt MsgRefreshToken
var err error
err = c.BodyParser(&rt)
if err != nil {
return failOnError(c, err, "cannot recieve parameter", fiber.StatusBadRequest)
}
token, err := jwt.Parse(rt.Token, VerifyToken)
if err != nil {
return failOnError(c, err, "token signing error", fiber.StatusBadRequest)
}
if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid {
return failOnError(c, err, "Refresh token expired", fiber.StatusUnauthorized)
}
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
uid, ok := claims["sub"].(string)
if !ok {
return failOnError(c, err, "token signing error", fiber.StatusBadRequest)
}
refreshAccesss, ok := claims["refresh_uuid"].(string)
if !ok {
return failOnError(c, err, "token signing error", fiber.StatusBadRequest)
}
_, err = deleteAuthFromRedis(c, refreshAccesss)
if err != nil {
return failOnError(c, err, "StatusUnauthorized", fiber.StatusUnauthorized)
}
t, err := createToken(uid)
if err != nil {
return failOnError(c, err, "StatusForbidden", fiber.StatusForbidden)
}
err = storeJWTAuthToRedis(c, uid, t)
if err != nil {
return failOnError(c, err, "StatusForbidden", fiber.StatusForbidden)
}
c.Status(fiber.StatusOK).JSON(fiber.Map{
"access_token": t.Token.AccessToken,
"refresh_token": t.Token.RefreshToken,
})
return nil
} else {
return failOnError(c, err, "refresh expired", fiber.StatusUnauthorized)
}
}

เป็นอันจบสิ้นในส่วนของการทำ refresh และ access token บน go fiber และ redis พอเขียนมาถึงตรงนี้ เลยนึกได้ว่า เหี้ยแล้ว กูลืมเขียน test นี่หว่า

โค้ดตัวอย่างที่ทำไว้อยู่นี่ https://github.com/aofiee/go-fiber-jwt-auth เจอกันรอบหน้า จะทำการเชื่อมต่อ database เพื่อทำ authentication เข้ามาให้ครบ flow

Thank you Ref: https://learn.vonage.com/blog/2020/03/13/using-jwt-for-authentication-in-a-golang-application-dr/


Tags

#Go lang#JWT#redis

Share

Previous Article
บันทึก การทำ Go fiber กับ JWT Middleware
Khomkrid Lerdprasert

Khomkrid Lerdprasert

Full Stack Life

Related Posts

สร้าง Key pair เพื่อทำการ signing document signature ด้วย Go lang
March 13, 2024
1 min
© 2024, All Rights Reserved.
Powered By

Quick Links

Author

Social Media