package xguard import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "time" ) // Event webhook dari X-Guard (panel Webhook di rincian aplikasi). Event pengguna hanya dikirim ke aplikasi yang boleh // dibuka pengguna itu. const ( EventUserCreated = "user.created" // akun aktif pertama kali EventUserUpdated = "user.updated" // nama atau email berubah EventUserDisabled = "user.disabled" // ditangguhkan atau dinonaktifkan: akhiri sesinya EventUserEnabled = "user.enabled" // aktif kembali EventUserDeleted = "user.deleted" // dihapus EventUserAppsChanged = "user.apps_changed" // ditambah ke atau dikeluarkan dari daftar akses aplikasi ini (Access) EventWebhookPing = "webhook.ping" // uji kirim dari konsol, tanpa Sub ) // Nilai Event.Access untuk user.apps_changed. const ( AccessGranted = "granted" AccessRevoked = "revoked" ) // Header webhook. const ( HeaderEventID = "X-XGuard-Event-Id" HeaderEventType = "X-XGuard-Event-Type" HeaderTimestamp = "X-XGuard-Timestamp" HeaderSignature = "X-XGuard-Signature" ) // MaxWebhookSkew: selisih maksimal X-XGuard-Timestamp dengan jam aplikasi. const MaxWebhookSkew = 5 * time.Minute var ( ErrWebhookSignature = errors.New("xguard: tanda tangan webhook salah") ErrWebhookTimestamp = errors.New("xguard: timestamp webhook di luar toleransi") ) // Event: satu event dari X-Guard. ID unik per pengiriman dan tetap sama saat X-Guard mencoba ulang, jadi pakai ID untuk // idempotensi. type Event struct { ID string `json:"id"` Type string `json:"type"` Sub string `json:"sub"` Email string `json:"email"` Access string `json:"access"` // user.apps_changed: AccessGranted atau AccessRevoked OccurredAt time.Time `json:"occurred_at"` } // VerifyWebhook memeriksa X-XGuard-Timestamp dan X-XGuard-Signature // (sha256=hex(HMAC-SHA256(secret, timestamp + "." + body))) lalu mengurai event. func VerifyWebhook(secret, timestamp, signature string, body []byte, now time.Time) (*Event, error) { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return nil, ErrWebhookTimestamp } if d := now.Sub(time.Unix(ts, 0)); d > MaxWebhookSkew || d < -MaxWebhookSkew { return nil, ErrWebhookTimestamp } m := hmac.New(sha256.New, []byte(secret)) m.Write([]byte(timestamp)) m.Write([]byte(".")) m.Write(body) want := "sha256=" + hex.EncodeToString(m.Sum(nil)) if !hmac.Equal([]byte(strings.TrimSpace(signature)), []byte(want)) { return nil, ErrWebhookSignature } var ev Event if err := json.Unmarshal(body, &ev); err != nil || ev.ID == "" || ev.Type == "" || (ev.Sub == "" && ev.Type != EventWebhookPing) { return nil, errors.New("xguard: body webhook tidak valid") } return &ev, nil } // WebhookHandler menerima webhook X-Guard. handle dipanggil untuk event yang // tanda tangannya valid. Kembalikan nil bila event sudah diproses, termasuk // event yang sudah pernah diterima (cek Event.ID) atau sub yang tidak dikenal // aplikasi. Kembalikan error supaya X-Guard mencoba ulang nanti. // Balasan: 204 OK, 401 tanda tangan/timestamp salah, 400 body rusak, 500 gagal proses. func WebhookHandler(secret string, handle func(ctx context.Context, ev *Event) error) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) if err != nil { http.Error(w, "body terlalu besar", http.StatusBadRequest) return } ev, err := VerifyWebhook(secret, r.Header.Get(HeaderTimestamp), r.Header.Get(HeaderSignature), body, time.Now()) switch { case errors.Is(err, ErrWebhookSignature), errors.Is(err, ErrWebhookTimestamp): http.Error(w, "unauthorized", http.StatusUnauthorized) return case err != nil: http.Error(w, "bad request", http.StatusBadRequest) return } if err := handle(r.Context(), ev); err != nil { http.Error(w, "gagal memproses event", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }) }