โดย 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}}/loginContent-Type: {{contentType}}{"username":"aofiee","password":"password"}###GET http://{{host}}/profileAuthorization: Bearer xxxx###DELETE http://{{host}}/logoutAuthorization: Bearer xxxx###POST http://{{host}}/refreshContent-Type: application/x-www-form-urlencodedrefresh_token=xxxx###POST http://{{host}}/refreshContent-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.conf13 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.6container_name: dbports:- 3308:3306volumes:- './database:/var/lib/mysql'environment:MYSQL_ROOT_PASSWORD: \t=H9fB_uy/2A\axMYSQL_DATABASE: diablosnetworks:- diablosphpmyadmin:image: phpmyadmin/phpmyadmincontainer_name: pmaports:- 8000:80environment:PMA_PASSWORD: \t=H9fB_uy/2A\axPMA_USER: rootPMA_HOSTS: databasenetworks:- diablosrabbitmq:image: rabbitmq:management-alpinecontainer_name: rabbitmqports:- 5672:5672- 15672:15672volumes:- './queue/data:/var/lib/rabbitmq'- './queue/log:/var/log/rabbitmq'networks:- diablosredis:image: 'redis:latest'container_name: redishostname: rediscommand: ['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=masternetworks:- diablosapi:# image: golang:1.16-alpinebuild:context: .dockerfile: ./api/go-dev.Dockerfilecontainer_name: apivolumes:- './api:/app'working_dir: /app/cmdports:- 8181:8181# command: go run main.golinks:- database- rabbitmq- redisnetworks:- diablosnetworks:diablos:driver: bridge
โดยเราจะใช้ Live reload บน golang ด้วย AIR
FROM golang:1.16-alpineRUN mkdir /appADD . /app/WORKDIR /app/cmdRUN go get -v github.com/cosmtrek/airENTRYPOINT ["air"]
เมื่อเราเตรียม config เสร็จแล้วก็ docker-compose up -d ขึ้นมา
ผมใช้ Medis Medis - GUI for Redis ในการทดสอบ login เข้าไปยัง redis server ดูก่อน
จากนั้นใน 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 MsgLoginuid := "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 = attokenDetail.RefreshTokenExp = rttokenDetail.AccessUUid = utils.UUIDv4()tokenDetail.RefreshUUid = utils.UUIDv4()var err error/////mock data/////ctAccess := MsgJWTContext{}ctAccess.DisplayName = "Khomkrid L."roles := []string{"admin","report",}ctAccess.Roles = rolestokenDetail.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"] = uidclaims["exp"] = expireclaims["iat"] = time.Now().In(location).Unix()claims["context"] = ctxif 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 errorvar 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 MsgLoginuid := "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 MsgRefreshTokenvar err errorerr = 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/
Quick Links
Legal Stuff