DEV Community 👩‍💻👨‍💻

Cover image for [ID] Otentikasi Skema Hybrid: JWT + userinfo
Muhammad Yahya Muhaimin for Hash Rekayasa Teknologi

Posted on • Updated on

[ID] Otentikasi Skema Hybrid: JWT + userinfo

Sebagai pengembang backend, barangkali Anda pernah membangun sendiri sistem otentikasi untuk aplikasi yang Anda buat. Jika Anda mengelola session, misalnya dengan memanfaatkan database, maka itu disebut stateful.
Kebalikan dari stateful adalah stateless. Beberapa standar otentikasi stateless yang umum di antaranya JWT dan SAML.[1]

Di artikel ini kita akan mengupas bagaimana JWT digunakan dan mengapa kita tidak bisa mengandalkan dia seutuhnya.

Aplikasi demo yang akan kita buat berbasis framework Fiber.
Jika Anda punya preferensi lain, secara prinsip soal JWT dan otentikasi skema hybrid ini harusnya sama.

Development Tools

Cara Kerja JWT

JWT (dibaca "jot") merupakan sebuah standar internet untuk pertukaran informasi, dituangkan dalam dokumen RFC 7519.
JSON Web Token, sesuai namanya, dia membawa payload dalam bentuk objek JSON. Setiap properti di dalamnya disebut dengan claim.

Langsung saja kita mulai dengan membuat sebuah folder, misalnya dengan nama how-to-hybrid-auth. Kemudian masuk ke folder tersebut, buka terminal dan ketik:

go mod init how-to-hybrid-auth
Enter fullscreen mode Exit fullscreen mode

Maka akan muncul file go.mod.
File ini bersama dengan file go.sum nantinya digunakan untuk mencatat berbagai dependency project. Kecuali project yang zero dependency, hanya membutuhkan standard packages, maka hanya akan ada file go.mod, tanpa file go.sum.

Di tutorial ini struktur projectnya seperti ini:

.
├── go.mod
├── go.sum
├── main.go
└── pkg
    ├── authenticate
    │   └── handler.go
    ├── getaccess
    │   └── handler.go
    ├── getprofile
    │   └── handler.go
    ├── jwt
    │   └── jwt.go
    └── store
        ├── seeder.go
        └── store.go
Enter fullscreen mode Exit fullscreen mode

1. Data contoh: users

Misalnya ada data profil pengguna sebagai dasar untuk mengidentifikasi pengguna aplikasi. Di sini kita simpan datanya dalam file JSON.

Isi file pkg/store/store.go:

package store

import (
    "encoding/json"
    "os"
)

type (
    Item map[string]interface{}
    Data map[string]Item
)

func Get(storeName string) (Data, error) {
    // Read file content.
    content, err := os.ReadFile(storeName + ".json")
    if err != nil {
        return nil, err
    }

    // Parse it.
    var data Data
    if err := json.Unmarshal(content, &data); err != nil {
        return nil, err
    }

    return data, nil
}

func Set(storeName string, data Data, overwrite bool) error {
    // Serialize the data.
    out, err := json.MarshalIndent(data, "", "  ")
    if err != nil {
        return err
    }

    if overwrite {
        // Write file (overwrite existing file).
        return os.WriteFile(storeName+".json", out, os.ModePerm)
    }

    // Check if file doesn't exist.
    if _, err := os.Stat(storeName + ".json"); errors.Is(err, os.ErrNotExist) {
        // Write file.
        return os.WriteFile(storeName+".json", out, os.ModePerm)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Fungsi Set untuk memformat data menjadi JSON dan menulisnya ke file, sedangkan fungsi Get untuk membaca file JSON dan mengonversinya menjadi data.

Dan isi file pkg/store/seeder.go:

package store

func Seed() error {
    users := Data{
        "rI3sMqctRUv9Cv9CdvJIV": Item{
            "name": "Agni",
            "role": "Owner",
        },
        "ELjvSoCYudoMnlEYlhCjP": Item{
            "name": "Bisma",
            "role": "Maintainer",
        },
        "5hkcRZcrOWTxaeFhq3EdD": Item{
            "name": "Catur",
            "role": "Developer",
        },
    }
    if err := Set("users", users, false); err != nil {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Fungsi Seed akan membuatkan data awal jika belum ada.

Sekarang gunakan fungsi tersebut di entry point aplikasi, yaitu file main.go:

package main

import (
    "how-to-hybrid-auth/pkg/store"
)

func main() {
    // Seed some data.
    if err := store.Seed(); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Jika kita jalankan aplikasi dengan perintah:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Maka akan muncul file users.json, yang berisi data awal seperti definisi di atas.

2. Membuat Access Token

Di sini kita mulai menggunakan Fiber, diawali dengan sebuah endpoint API untuk membuat Access Token.

Ubah file main.go seperti berikut:

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"

    "how-to-hybrid-auth/pkg/getaccess"
    "how-to-hybrid-auth/pkg/store"
)

func main() {
    // Seed some data.
    if err := store.Seed(); err != nil {
        panic(err)
    }

    // Create Fiber app.
    app := fiber.New()

    // External endpoints.
    app.Post("/get-access", getaccess.Handle)

    log.Fatal(app.Listen(":3000"))
}
Enter fullscreen mode Exit fullscreen mode

Untuk memfungsikan endpoint tersebut, pertama siapkan satu fungsi khusus untuk membuat token JWT di file pkg/jwt/jwt.go:

package jwtutil

import (
    "time"

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

const (
    SigMethod = "HS256"
    SigKey    = "2022terceS"
    Issuer    = "localhost"
    Audience  = "localhost"
)

// Sign creates a token from the specified claims.
func Sign(claims jwt.MapClaims, expiresIn time.Duration) string {
    claims["iss"] = Issuer
    claims["aud"] = Audience

    now := time.Now()
    claims["iat"] = now.Unix()
    claims["exp"] = now.Add(expiresIn).Unix()

    token := jwt.NewWithClaims(jwt.GetSigningMethod(SigMethod), claims)
    out, _ := token.SignedString([]byte(SigKey))

    return out
}
Enter fullscreen mode Exit fullscreen mode

Di sini kita menggunakan signing method yang mudah dulu, yaitu HS256, di mana signing key yang digunakan sifatnya simetris: Key yang digunakan sama persis untuk signing dan verifikasi JWT.[2]

Issuer = Pihak yang menerbitkan JWT. Audience = Pihak yang menggunakan JWT.
Jika dibuat sama, bisa bermaksud bahwa JWT diterbitkan dan digunakan sendiri. Meskipun itu bukan berarti JWT tersebut tidak akan berpindah tangan.
Setidaknya antara aplikasi server dan aplikasi client sudah termasuk dua pihak yang berbeda. JWT diterbitkan oleh server, kemudian "dipinjamkan" ke client, tetapi hanya server yang berhak untuk memverifikasinya.

Setelah itu buat fungsi handler yang dipasangkan ke endpoint /get-access itu tadi, yaitu di file pkg/getaccess/handler.go:

package getaccess

import (
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v4"

    jwtutil "how-to-hybrid-auth/pkg/jwt"
    "how-to-hybrid-auth/pkg/store"
)

type (
    findUser struct {
        ID string `form:"id"`
    }

    accessResponse struct {
        AccessToken string `json:"access_token"`
    }
)

func Handle(c *fiber.Ctx) error {
    // Find user by ID.
    fUser := new(findUser)
    if err := c.BodyParser(fUser); err != nil {
        return err
    }

    users, err := store.Get("users")
    if err != nil {
        return err
    }

    user, ok := users[fUser.ID]
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    // Generate access token.
    accessClaims := jwt.MapClaims{
        "sub": fUser.ID,
    }

    for k, v := range user {
        accessClaims[k] = v
    }

    accessToken := jwtutil.Sign(accessClaims, 5*time.Minute)

    return c.JSON(accessResponse{accessToken})
}
Enter fullscreen mode Exit fullscreen mode

Fungsi getaccess.Handle akan mencari user berdasarkan ID. Jika ada, maka ambil semua informasi dari user tersebut menjadi claim-claim JWT, kemudian operkan ke fungsi jwtutil.Sign untuk digabungkan dengan beberapa claim khusus.
Untuk demo ini, expiresIn (masa berlaku JWT) kita buat 5 menit saja, yang kira-kira cukup untuk melihat perubahan dari valid menjadi expired.
Terakhir dilakukan signing, sehingga menghasilkan token JWT.

Karena sampai di bagian ini kita sudah menggunakan beberapa dependency ekstra (3rd party packages), antara lain gofiber/fiber/v2 dan golang-jwt/jwt/v4, maka kita perlu panggil perintah berikut:

go mod tidy
Enter fullscreen mode Exit fullscreen mode

Dengan begitu semua dependency tersebut menjadi tersedia untuk project ini.

Sekarang jalankan lagi aplikasi:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Fiber startup message

Itu menunjukkan bahwa aplikasi ini berjalan dengan Fiber, berupa server HTTP di port 3000.

Kita coba panggil endpoint yang dibuat tadi.
Tambahkan satu request di Postman seperti berikut:

Postman - POST /get-access

Setelah klik Send, maka akan dapat jawaban seperti berikut:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb2NhbGhvc3QiLCJleHAiOjE2NTI5NzA2MzYsImlhdCI6MTY1Mjk3MDMzNiwiaXNzIjoibG9jYWxob3N0IiwibmFtZSI6IkNhdHVyIiwicm9sZSI6IkRldmVsb3BlciIsInN1YiI6IjVoa2NSWmNyT1dUeGFlRmhxM0VkRCJ9.Qo70QaMH0NVo11i8u1uXgJssor1iAtGruXPiWG2PGF0"
}
Enter fullscreen mode Exit fullscreen mode

3. Bedah Singkat Token JWT

Coba salin jawaban access_token di atas, atau yang Anda tes sendiri, ke website jwt.io.

Copas token JWT ke jwt.io

Di situ kita bisa mengintip isi dari token, ada tiga bagian: Header, Payload dan Signature.

  • Bagian Header tidak saya jelaskan.
  • Bagian Payload berisi semua claim yang sudah disiapkan sebelumnya.
  • Bagian Signature digunakan untuk verifikasi. Bisa coba salin signing key yang didefinisikan di source code: "2022terceS".

Signature Verified di jwt.io

Sekarang status di pojok kiri-bawah menjadi "Signature Verified".

Nah, token yang bisa di-parse sebelum diverifikasi seperti itu berarti dia termasuk jenis JWS (JSON Web Signature).
Jenis lainnya adalah JWE (JSON Web Encryption), yang isinya terenkripsi dan membutuhkan sebuah secret key untuk membukanya.

JWT berjenis JWS atau JWE

Jadi sebuah token JWT pasti berbentuk salah satu dari dua jenis tersebut.
Yang biasanya digunakan adalah JWS, berisi tiga bagian yang disebutkan di atas. Sedangkan JWE berisi lima bagian, dan salah satu metode enkripsinya adalah AES256-GCM.[3][4]

4. JWT Middleware (Parse + Verifikasi)

Middleware untuk JWT termasuk salah satu yang dibuat oleh para maintainer Fiber, jadi kita tinggal memanfaatkannya saja.

Isi file pkg/authenticate/handler.go:

package authenticate

import (
    "github.com/gofiber/fiber/v2"
    jwtware "github.com/gofiber/jwt/v3"

    jwtutil "how-to-hybrid-auth/pkg/jwt"
)

func New() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningMethod: jwtutil.SigMethod,
        SigningKey:    []byte(jwtutil.SigKey),
        ContextKey:    "auth",
    })
}
Enter fullscreen mode Exit fullscreen mode

Supaya project mengenali dependency yang baru ditambahkan, panggil lagi perintah berikut:

go mod tidy
Enter fullscreen mode Exit fullscreen mode

Signing method dan signing key yang digunakan adalah yang ditetapkan di file pkg/jwt/jwt.go.
Dengan konfigurasi minimal seperti di atas, maka otomatis dilakukan verifikasi:

  • Signature.
  • Expiration.

Jika butuh memverifikasi hal lain, misalnya Audience, maka harus ditulis secara custom:

package authenticate

import (
    "fmt"

    "github.com/gofiber/fiber/v2"
    jwtware "github.com/gofiber/jwt/v3"
    "github.com/golang-jwt/jwt/v4"

    jwtutil "how-to-hybrid-auth/pkg/jwt"
)

func New() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningMethod: jwtutil.SigMethod,
        SigningKey:    []byte(jwtutil.SigKey),
        ContextKey:    "auth",
        SuccessHandler: func(c *fiber.Ctx) error {
            auth := c.Locals("auth").(*jwt.Token)
            claims := auth.Claims.(jwt.MapClaims)

            // Verify audience.
            if !claims.VerifyAudience(jwtutil.Audience, true) {
                msg := fmt.Sprintf("Invalid JWT audience. Expected: %s", jwtutil.Audience)
                return c.Status(fiber.StatusUnauthorized).SendString(msg)
            }

            return c.Next()
        },
    })
}
Enter fullscreen mode Exit fullscreen mode

Middleware ini belum bisa digunakan jika belum ada endpoint yang dituju. Jadi kita langsung beranjak ke poin berikutnya.

5. Meminta Data Profil Pengguna

Buat fungsi handler di file pkg/getprofile/handler.go:

package getprofile

import (
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v4"
)

var reservedClaims = map[string]struct{}{
    "iss": {},
    "aud": {},
    "exp": {},
    "nbf": {},
    "iat": {},
    "sub": {},
    "jti": {},
}

func Handle(c *fiber.Ctx) error {
    auth := c.Locals("auth").(*jwt.Token)
    claims := auth.Claims.(jwt.MapClaims)

    // Extract profile from JWT claims.
    profile := claims

    for k := range reservedClaims {
        delete(profile, k)
    }

    return c.JSON(profile)
}
Enter fullscreen mode Exit fullscreen mode

Profil pengguna diekstrak dari claim-claim JWT, dengan mengeliminasi tujuh claim khusus (jika ada) yang disebut dengan reserved claims.[5]

Dan ubah file main.go seperti berikut:

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"

    "how-to-hybrid-auth/pkg/authenticate"
    "how-to-hybrid-auth/pkg/getaccess"
    "how-to-hybrid-auth/pkg/getprofile"
    "how-to-hybrid-auth/pkg/store"
)

func main() {
    // Seed some data.
    if err := store.Seed(); err != nil {
        panic(err)
    }

    // Create Fiber app.
    app := fiber.New()

    // External endpoints.
    app.Post("/get-access", getaccess.Handle)

    // JWT middleware.
    app.Use(authenticate.New())

    // Internal endpoints.
    app.Post("/get-profile", getprofile.Handle)

    log.Fatal(app.Listen(":3000"))
}
Enter fullscreen mode Exit fullscreen mode

Jadi umumnya JWT middleware itu ditempatkan setelah endpoint-endpoint eksternal dan sebelum endpoint-endpoint internal.

Jalankan lagi aplikasi:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Kemudian ke Postman dan tambahkan satu request lagi:

Postman - POST /get-profile

Isikan Access Token ke kolom yang tersedia.

Jika token tidak valid atau sudah expired, maka akan dapat jawaban "Invalid or expired JWT".

Sedangkan jika valid, maka akan dapat jawaban seperti berikut:

{
    "name": "Catur",
    "role": "Developer"
}
Enter fullscreen mode Exit fullscreen mode

Keterbatasan JWT

JWT memang dirancang untuk pertukaran informasi. Namun satu problem yang jelas adalah ketika misalnya data profil diubah dan kita butuh agar perubahan tersebut langsung berdampak terhadap penggunaan aplikasi, maka pada kondisi tersebut JWT tidak bisa diandalkan.
Jika untuk melihat perubahan itu harus melakukan penerbitan ulang JWT, maka sepertinya itu skenario yang terlalu memaksakan, tidak praktis.

Fakta yang ada di lapangan, oleh berbagai sistem penyedia identitas (identity provider), yaitu antara dua kemungkinan:

  • Token JWT bukan sebagai Access Token, melainkan hanya sebagai ID Token. Ini yang dilakukan oleh Apple.[6]
  • Pihak provider menyediakan endpoint /userinfo dan butuh Access Token untuk memanggilnya. Ini yang dilakukan oleh Auth0.[7]

Dalam kasus Apple, mereka tidak peduli terhadap perubahan profil pengguna dan cukup ID Token itu yang dijadikan patokan, selama masih valid.
Dari sisi aplikasi, bisa menetapkan durasi expiration, seperti yang ada di library albenik-go/apple-sign-in pada fungsi Client.ValidateCode.

Dalam kasus Auth0, Access Token yang digunakan untuk memanggil endpoint /userinfo bersifat opaque (tidak transparan). Artinya ketika aplikasi memperoleh Access Token, tidak untuk di-parse, sebab bukan JWT.

Userinfo: Tantangan dan Solusinya

Kita tahu bahwa urusan otentikasi dapat dikelola secara internal maupun eksternal.

Meskipun di atas sudah disebutkan tentang keterbatasan pada JWT dan keunggulan adanya endpoint /userinfo, tapi kadang kita dihadapkan pada tantangan ketika aplikasinya harus mendukung beberapa penyedia identitas, contohnya Google dan Facebook.

  • Google punya endpoint /userinfo.
  • Facebook punya endpoint /me.

Kalau hanya satu sumber data, kita bisa mengandalkannya. Tapi kalau lebih dari satu, itu juga menjadi masalah.
Mungkin endpoint tersebut hanya berguna untuk membantu pendaftaran pengguna, untuk mengisi profil di awal. Yang berarti aplikasi harus menyimpan data profil secara lokal.
Dalam kasus yang seperti ini, tidak ada bedanya antara penyedia identitas menggunakan endpoint /userinfo atau ID Token, karena hanya diakses sekali untuk masing-masing pengguna.

Jadi dapat diambil kesimpulan bahwa harusnya selalu ada data profil lokal aplikasi, baik itu otentikasinya secara internal maupun eksternal.

Sekarang untuk penyesuaian yang diperlukan di aplikasi demo kita:

Di file pkg/getaccess/handler.go, baris 35-49:

    _, ok := users[fUser.ID]
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    // Generate access token.
    accessClaims := jwt.MapClaims{
        "sub": fUser.ID,
    }

    accessToken := jwtutil.Sign(accessClaims, 5*time.Minute)
Enter fullscreen mode Exit fullscreen mode

Di situ kita hanya membutuhkan informasi ID pengguna dan tidak menyertakan informasi lainnya untuk pembuatan Access Token.

Kemudian di file pkg/getprofile/handler.go:

package getprofile

import (
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v4"

    "how-to-hybrid-auth/pkg/store"
)

var reservedClaims = map[string]struct{}{
    "iss": {},
    "aud": {},
    "exp": {},
    "nbf": {},
    "iat": {},
    "sub": {},
    "jti": {},
}

func Handle(c *fiber.Ctx) error {
    auth := c.Locals("auth").(*jwt.Token)
    claims := auth.Claims.(jwt.MapClaims)

    // Extract user ID from JWT claims.
    userID, ok := claims["sub"].(string)
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    // Find user by ID.
    users, err := store.Get("users")
    if err != nil {
        return err
    }

    user, ok := users[userID]
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    return c.JSON(user)
}
Enter fullscreen mode Exit fullscreen mode

Di situ pertama kita memastikan bahwa JWT berisi claim "sub", yang itu adalah ID pengguna. Setelah itu kita mencari data pengguna berdasarkan ID tersebut.

Dengan begitu, hasil yang didapat akan selalu sesuai dengan data profil saat ini.

Jawaban pertama:

{
    "name": "Catur",
    "role": "Developer"
}
Enter fullscreen mode Exit fullscreen mode

Jawaban kedua setelah data berubah:

{
    "name": "Catur",
    "role": "Maintainer"
}
Enter fullscreen mode Exit fullscreen mode

Referensi

  1. https://doubleoctopus.com/security-wiki/network-architecture/stateless-authentication/
  2. https://youtu.be/XfjQ2qO4ca8
  3. https://developer.okta.com/blog/2020/12/21/beginners-guide-to-jwt
  4. https://medium.facilelogin.com/jwt-jws-and-jwe-for-not-so-dummies-b63310d201a3
  5. https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims
  6. https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
  7. https://auth0.com/docs/secure/tokens/access-tokens

Bacaan/Tontonan Lanjutan

  1. Bagaimana cara logout ketika menggunakan JWT: https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6
  2. Mengenal OpenID Connect: https://www.youtube.com/watch?v=6DxRTJN1Ffo&list=PLKCk3OyNwIzuD_jxWu-JddooM2yjX5q99&index=12

Top comments (0)

🌚 Browsing with dark mode makes you a better developer.

It's a scientific fact.