Dalam dunia pengembangan web modern, menciptakan alur autentikasi pengguna yang aman dan efisien adalah hal yang terpenting. Dengan meningkatnya layanan mikro dan arsitektur terdistribusi, penerapan strategi autentikasi yang kuat menjadi semakin penting.

Pola autentikasi Backend for Frontend (BFF) adalah pendekatan desain yang berfokus pada penyediaan backend khusus untuk menangani dan mengatasi semua persyaratan autentikasi dan tantangan aplikasi frontend (SPA).

TUJUAN

Tujuan utama blog teknologi ini adalah untuk mengedukasi pembaca tentang pola autentikasi Backend for Frontend (BFF) dan implementasinya dalam bahasa pemrograman Go. Blog ini akan memberikan gambaran komprehensif tentang pola tersebut, manfaatnya, dan tantangan yang dipecahkannya dalam konteks membangun sistem otentikasi untuk aplikasi front-end.

Sejarah

SPA biasanya menggunakan autentikasi berbasis token, seperti JSON Web Tokens (JWTs), untuk mengautentikasi dan mengotorisasi pengguna. Namun, mengelola token dengan aman di SPA dapat menjadi tantangan. Menyimpan token di penyimpanan sisi klien (misalnya penyimpanan lokal atau cookie) dapat membuat token terkena potensi serangan seperti pemalsuan permintaan lintas situs (CSRF). Pengembang harus menerapkan langkah-langkah keamanan yang ketat untuk melindungi token dan mencegah akses tidak sah atau penyalahgunaan.

Pola BFF memecahkan masalah ini dengan memperkenalkan lapisan perantara — Backend untuk Frontend. Lapisan ini bertindak sebagai proxy antara klien front-end dan layanan backend utama, menangani masalah terkait otentikasi dan menyediakan antarmuka otentikasi khusus.

Diagram Alir

1. Ketika frontend perlu mengautentikasi pengguna, frontend akan memanggil titik akhir API (/login) di BFF untuk memulai jabat tangan login.

2. BFF menggunakan Aliran Kode Otorisasi OAuth2 untuk terhubung dengan Auth0 guna mengautentikasi dan mengotorisasi pengguna serta mendapatkan id dan token akses.

3. Backend menyimpan token pengguna dalam cache.

4. Cookie terenkripsi dikeluarkan untuk frontend yang mewakili sesi otentikasi pengguna.

5. Ketika frontend perlu memanggil API eksternal, frontend meneruskan cookie terenkripsi ke BFF bersama dengan URL dan data untuk memanggil API.

6. BFF mengambil token akses dari cache dan membuat panggilan ke API backend termasuk token tersebut pada header otorisasi.

7. Ketika API eksternal mengembalikan respons ke BFF, API ini meneruskan respons tersebut kembali ke frontend.

Implementasi

Prasyarat

Sebagai prasyarat untuk memahami sepenuhnya solusi yang diusulkan, saya menyarankan Anda mendapatkan gambaran tentang topik berikut jika Anda belum menyadarinya.

1. Daftarkan Aplikasi Web Reguler di Auth0

2. Pekerjaan Kerangka Web Iris

3. Alur Kode Otorisasi

Struktur Proyek

Memulai

Untuk memulai proses implementasi, saya mengacu pada Panduan Auth0 untuk mendaftarkan aplikasi web saya. Mengikuti panduan ini, saya berhasil mengatur otentikasi pengguna untuk aplikasi web menggunakan Auth0. Dengan fungsi otentikasi awal yang ada, saya melanjutkan untuk memodifikasi kode untuk memasukkan Pola Otentikasi Backend for Frontend (BFF) router.go

router.pergi

Di router.go saya mendefinisikan semua rute yang diperlukan untuk mengautentikasi dan mengotorisasi pengguna.

  • `/login`: Titik akhir ini dipicu ketika pengguna mengklik tombol login di frontend.
  • `/logout`: Frontend merutekan ke titik akhir ini ketika pengguna mengklik tombol logout.
  • `/callback`: Titik akhir ini bertanggung jawab atas proses pertukaran token dengan kode otorisasi.
  • `/shorten`: Ini adalah salah satu API backend yang akan digunakan oleh frontend.
type router struct{}

func (router *router) InitRouter(auth *authenticator.Authenticator, redis interfaces.IRedisLayer) *iris.Application {
 app := iris.New()

 loginHandler := controller.LoginHandler{Auth: auth}
 callbackHandler := controller.CallbackHandler{Auth: auth, RedisClient: redis}
 logoutHandler := controller.LogoutHandler{RedisClient: redis}
 backendApiHandler := controller.BackendApiHandler{RedisClient: redis}
 middlewareHandler := middleware.MiddlewareHandler{RedisClient: redis}

 app.Get("/login", loginHandler.Login)
 app.Get("/callback", callbackHandler.Callback)
 app.Get("/logout", logoutHandler.Logout)

 // Backend Api
 app.Post("/shorten", middlewareHandler.IsAuthenticated, backendApiHandler.WriterRedirect)

 return app
}

Penting untuk memperhatikan penggunaan pengendali middlewareHandler.IsAuthenticated di rute API backend. Penangan ini memainkan peran penting dalam memvalidasi profil login pengguna. Anda dapat menemukan detail implementasi di bagian Middleware

Pengendali

Di pengontrol, saya telah merangkum logika untuk semua rute yang disebutkan sebelumnya. Di sinilah rincian implementasi untuk setiap rute ditentukan dan ditangani.

  • login.go
    Dalam rute login, pengendali mengarahkan pengguna ke halaman login Universal Penyedia Identitas (IDP). Halaman ini memungkinkan pengguna untuk melakukan sistem masuk tunggal dan memberikan persetujuannya.
    Setelah pengguna memberikan persetujuan pada halaman login IDP Universal, halaman tersebut dialihkan kembali ke titik akhir /callback bersama dengan kode otorisasi.
type LoginHandler struct {
 Auth *authenticator.Authenticator
}

func (l *LoginHandler) Login(ctx iris.Context) {
 ctx.Redirect(l.Auth.AuthCodeURL(state, oauth2.SetAuthURLParam("audience", config.EnvVariables.Auth0Audience)), http.StatusTemporaryRedirect)
}

Parameter audiens memainkan peran penting dalam memberikan klaim audiens dalam payload, yang membantu otorisasi pengguna. Untuk pemahaman yang lebih mendetail, disarankan untuk merujuk ke “sumber daya tambahan”.
Tantangan utamanya adalah menyertakan parameter URL audiens dalam fungsi l.Auth.AuthCodeURL(). Dengan memeriksa kode sumber dari paket oauth2 dan memahami implementasinya, saya memperoleh wawasan tentang cara meneruskan parameter audiens menggunakan oauth2.SetAuthURLParam().

  • callback.go
    Setelah pengguna mengunjungi rute /login, URL /callback dipanggil, meneruskan kode status dan otorisasi sebagai parameter.
    Pengendali yang bertanggung jawab atas /callback URL memvalidasi nilai parameter status untuk memastikan integritas dan keamanannya.
    Setelah nilai status berhasil divalidasi, pengendali melanjutkan untuk menukar kode otorisasi dengan token akses.
    Selain pertukaran token, pengendali mengambil langkah-langkah yang diperlukan untuk menyimpan token dan informasi profil pengguna di Redis, penyimpanan data yang biasa digunakan untuk cache dan manajemen sesi.
type CallbackHandler struct {
 Auth        *authenticator.Authenticator
 RedisClient interfaces.IRedisLayer
}

func (c *CallbackHandler) Callback(ctx iris.Context) {
 if ctx.URLParam("state") != state {
  ctx.StopWithJSON(http.StatusBadRequest, "Invalid state parameter.")
  return
 }

 // Exchange an authorization code for a token.
 token, err := c.Auth.Exchange(ctx.Request().Context(), ctx.URLParam("code"))
 if err != nil {
  ctx.StopWithJSON(http.StatusUnauthorized, "Failed to convert an authorization code into a token.")
  return
 }

 idToken, err := c.Auth.VerifyIDToken(ctx.Request().Context(), token)
 if err != nil {
  ctx.StopWithJSON(http.StatusInternalServerError, "Failed to verify ID Token.")
  return
 }

 var profile map[string]interface{}
 if err := idToken.Claims(&profile); err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 err = c.RedisClient.SetKeyValue(profile["email"].(string)+"_token", token.AccessToken, 24*time.Hour)
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }
 err = c.RedisClient.HSetKeyValue(profile["email"].(string)+"_profile", profile, 24*time.Hour)
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 ctx.SetCookieKV("logged_id_email", profile["email"].(string))

 // Redirect to logged in page.
 ctx.Redirect(config.EnvVariables.FrontendURL, http.StatusTemporaryRedirect)
}

Untuk menyimpan informasi token dan profil, saya telah menerapkan Redis sebagai lapisan cache. Saya membuat kunci untuk setiap pengguna dengan mengawalinya dengan alamat email mereka untuk memastikan keunikan dan kemudahan pengambilan data.
Selain itu, fungsi ctx.SetCookieKV() digunakan untuk mengatur enkripsi Cookie hanya HTTP. Cookie ini nantinya dikirim oleh frontend saat melakukan panggilan ke API backend

  • logout.go
    Saat pengguna mengklik tombol logout, pengendali logout melakukan dua tindakan utama. Pertama, ini menghapus semua nilai yang di-cache, memastikan bahwa setiap token yang disimpan atau informasi profil pengguna dihapus. Kedua, ini memulai proses logout dengan Auth0 (IDP), yang secara efektif mengeluarkan pengguna dari Penyedia Identitas
 type LogoutHandler struct {
 RedisClient interfaces.IRedisLayer
}

func (l *LogoutHandler) Logout(ctx iris.Context) {
 userCookie := ctx.GetCookie("logged_id_email")
 if userCookie == "" {
  ctx.StopWithError(iris.StatusUnauthorized, errors.New("please make sure user is logged in"))
  return
 }

 // delete token key
 err := l.RedisClient.DeleteKey(userCookie + "_token")
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 // delete profile key
 err = l.RedisClient.DeleteKey(userCookie + "_profile")
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 logoutUrl, err := url.Parse("https://" + config.EnvVariables.Auth0Domain + "/v2/logout")
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 returnTo, err := url.Parse(config.EnvVariables.ShortifyFrontendDomain)
 if err != nil {
  ctx.StopWithError(http.StatusInternalServerError, err)
  return
 }

 // remove the logged_id_email http-only cookie from context
 ctx.RemoveCookie("logged_id_email")

 parameters := url.Values{}
 parameters.Add("returnTo", returnTo.String())
 parameters.Add("client_id", config.EnvVariables.Auth0ClientID)
 logoutUrl.RawQuery = parameters.Encode()

 ctx.Redirect(logoutUrl.String(), http.StatusTemporaryRedirect)
}

Untuk menghapus informasi cache, termasuk profil pengguna dan token yang disimpan di Redis, saya menggunakan fungsi RedisClient.DeleteKey(). Hal ini memastikan bahwa data yang relevan dihapus dari cache. Selain itu, memanggil API /v2/logout Auth0 juga memungkinkan kami mengeluarkan pengguna dari Auth0 secara efektif.
Untuk menghapus cookie tertentu, ctx.RemoveCookie() digunakan. Fungsi ini secara khusus menargetkan dan menghapus cookie ‘logged_id_email’, yang awalnya disetel selama pengendali /callback.

  • backendApi.go
    Saat frontend membuat permintaan ke salah satu API backend, Backend untuk Frontend (BFF) memastikan bahwa frontend mengambil token pengguna dari cache. Token ini penting untuk tujuan autentikasi dan otorisasi.
    BFF kemudian menambahkan token yang diambil ke Header Otorisasi permintaan dan meneruskannya ke API backend.
    Setelah API backend memproses permintaan dan menghasilkan a respon, BFF bertindak sebagai proxy dan mengirimkan respon kembali ke frontend, memungkinkan komunikasi yang lancar antara frontend dan backend.
type BackendApiHandler struct {
 RedisClient interfaces.IRedisLayer
}

func (w *BackendApiHandler) WriterRedirect(ctx iris.Context) {
 raw, err := ctx.User().GetRaw()
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }
 profile := raw.(map[string]string)
 email := profile["email"]

 token, err := w.RedisClient.GetKeyValue(email + "_token")
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 client := &http.Client{}
 req, err := http.NewRequest(ctx.Request().Method, config.EnvVariables.BackendApi, ctx.Request().Body)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 req.Header.Add("Authorization", "Bearer "+token)
 req.Header.Add("Content-Type", "application/json")

 res, err := client.Do(req)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }
 defer res.Body.Close()

 body, err := io.ReadAll(res.Body)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 var respBody map[string]interface{}
 err = json.Unmarshal(body, &respBody)
 if err != nil {
  ctx.StopWithError(500, err)
  return
 }

 ctx.StopWithJSON(res.StatusCode, respBody)
}

Perhatikan bahwa ctx.User().GetRaw() mengambil informasi profil pengguna yang disetel selama middleware.IsAuthenticated() handler yang ditentukan di bawah. Dengan bantuan email dari ctx.User() saya mendapatkan token dari cache redis dan menambahkan header otorisasi.

Perangkat Tengah

Middleware memainkan peran penting dalam memvalidasi cookie terenkripsi yang dikirim dari frontend bersama dengan permintaan API dan juga memeriksa apakah pengguna sudah login.

  • isAuhtenticated.go
type MiddlewareHandler struct {
 RedisClient interfaces.IRedisLayer
}

// IsAuthenticated is a middleware that checks if
// the user has already been authenticated previously.
func (m *MiddlewareHandler) IsAuthenticated(ctx iris.Context) {
 userCookie := ctx.GetCookie("logged_id_email")
 if userCookie == "" {
  ctx.StopWithError(iris.StatusUnauthorized, errors.New("please make sure user is logged in"))
  return
 }

 value, err := m.RedisClient.HGetKeyValue(userCookie + "_profile")
 if err != nil || value == nil {
  ctx.StopWithError(iris.StatusUnauthorized, errors.New("please make sure user is logged in"))
  return
 }

 ctx.SetUser(value)
 ctx.Next()
}

Dengan memanfaatkan ctx.SetUser(), informasi profil pengguna disimpan dalam konteks Iris. Informasi ini dapat diakses dan digunakan oleh pengendali API backend selama pemrosesan permintaan.

Pengautentik

Authenticator menginisialisasi instance baru dari penyedia Oauth2.

// Authenticator is used to authenticate our users.
type Authenticator struct {
 *oidc.Provider
 oauth2.Config
}

// New instantiates the *Authenticator.
func New() (*Authenticator, error) {
 provider, err := oidc.NewProvider(
  context.Background(),
  "https://"+config.EnvVariables.Auth0Domain+"/",
 )
 if err != nil {
  return nil, err
 }

 conf := oauth2.Config{
  ClientID:     config.EnvVariables.Auth0ClientID,
  ClientSecret: config.EnvVariables.Auth0ClientSecret,
  RedirectURL:  config.EnvVariables.Auth0CallbackURL,
  Endpoint:     provider.Endpoint(),
  Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
 }

 return &Authenticator{
  Provider: provider,
  Config:   conf,
 }, nil
}

// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
 rawIDToken, ok := token.Extra("id_token").(string)
 if !ok {
  return nil, errors.New("no id_token field in oauth2 token")
 }

 oidcConfig := &oidc.Config{
  ClientID: a.ClientID,
 }

 return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}

Sangat penting untuk memastikan bahwa semua variabel lingkungan aplikasi Auth0 dikonfigurasi dan disetel dengan benar. Langkah ini diperlukan agar berhasil menginisialisasi penyedia OAuth2.

Tentang Arus

Inilah yang terjadi ketika pengguna mengautentikasi dengan aplikasi:

  1. Pengguna mengklik tombol login di UI frontend.
  2. Frontend mengalihkan pengguna ke endpoint /login di Backend for Frontend (BFF).
  3. BFF selanjutnya mengarahkan pengguna ke Halaman Login Universal Auth0.
  4. Pengguna memberikan kredensial dan persetujuannya untuk proses otentikasi.
  5. Setelah autentikasi berhasil, permintaan panggilan balik dikirim kembali ke titik akhir /callback dalam aplikasi.
  6. Pengendali panggilan balik memvalidasi parameter status yang diterima, memastikan integritas dan keamanannya.
  7. Penangan panggilan balik menukar kode otorisasi yang diterima dengan token akses melalui saluran aman.
  8. Token akses yang diterima divalidasi.
  9. Token akses dan informasi profil terkait diekstraksi dan disimpan dalam cache di server Redis.
  10. Penangan mengalihkan pengguna kembali ke UI frontend, bersama dengan cookie khusus HTTP.
  11. Saat pengguna melakukan panggilan berikutnya ke API backend dari frontend, cookie disertakan dalam permintaan.
  12. Cookie divalidasi oleh pengendali middleware, yang juga memverifikasi status login pengguna dan memeriksa ketersediaan informasi profil di cache.
  13. Penangan API backend mengelola aliran, mengekstrak token akses JWT dari cache, dan menambahkannya ke Header Otorisasi dari permintaan yang diteruskan.
  14. Permintaan tersebut kemudian diteruskan ke API backend.
  15. Setelah respons diterima dari backend, respons tersebut diteruskan kembali ke frontend.

Kesimpulan

Kesimpulannya, pola autentikasi Backend for Frontend (BFF) di Go memberikan pendekatan yang kuat dan fleksibel untuk membangun sistem autentikasi untuk aplikasi front-end. Dengan memisahkan masalah autentikasi ke dalam layanan back-end khusus, yang disesuaikan untuk setiap klien front-end, pengembang dapat menciptakan sistem autentikasi yang kuat, terukur, dan aman yang memenuhi persyaratan unik aplikasi mereka.

Saya harap blog teknologi ini memberikan wawasan berharga dan panduan praktis untuk menerapkan pola otentikasi BFF di Go. Selamat coding!

Kode sumber contoh ini tersedia di mehulgohil/go-bffauth