From f5183d94fe264fd9954c0e6e862166db3e288ed3 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Sun, 3 Mar 2024 19:22:28 -0600 Subject: [PATCH] Initial commit --- .github/workflows/push.yaml | 19 ++++ Dockerfile | 8 ++ cache.go | 87 ++++++++++++++++ go.mod | 17 ++++ go.sum | 26 +++++ keycloak.go | 197 ++++++++++++++++++++++++++++++++++++ main.go | 126 +++++++++++++++++++++++ 7 files changed, 480 insertions(+) create mode 100644 .github/workflows/push.yaml create mode 100644 Dockerfile create mode 100644 cache.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 keycloak.go create mode 100644 main.go diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 0000000..793d72b --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,19 @@ +name: Release +on: [push] + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + name: Check out code + + - uses: mr-smithers-excellent/docker-build-push@v6 + name: Build and Push Image + with: + image: fobsvr + registry: ghcr.io + dockerfile: Dockerfile + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74450cc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.21 AS builder +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 go build + +FROM scratch +COPY --from=builder /app/fobsvr /fobsvr +ENTRYPOINT ["/fobsvr"] diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..7b9a3da --- /dev/null +++ b/cache.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "sync" + "time" +) + +type cache struct { + keycloak *keycloak + + lock sync.Mutex + state []*AccessUser + hash string + watchers map[chan struct{}]struct{} +} + +func newCache(k *keycloak) *cache { + return &cache{keycloak: k} +} + +func (c *cache) Fill() error { + ctx, done := context.WithTimeout(context.Background(), time.Minute) + defer done() + + users, err := c.keycloak.ListUsers(ctx) + if err != nil { + return err + } + + sort.Slice(users, func(i, j int) bool { return users[i].FobID < users[j].FobID }) + hash := calculateUsersHash(users) + + c.lock.Lock() + defer c.lock.Unlock() + + if c.hash == hash { + return nil // nothing has changed + } + c.state = users + c.hash = hash + + for ch := range c.watchers { + select { + case ch <- struct{}{}: + default: + } + } + + return nil +} + +func (c *cache) Load() ([]*AccessUser, string) { + c.lock.Lock() + defer c.lock.Unlock() + return c.state, c.hash +} + +func (c *cache) Wait(period time.Duration) { + ch := make(chan struct{}, 1) + c.lock.Lock() + c.watchers[ch] = struct{}{} + c.lock.Unlock() + + t := time.NewTimer(period) + defer t.Stop() + + select { + case <-ch: + case <-t.C: + } + + c.lock.Lock() + delete(c.watchers, ch) + c.lock.Unlock() + close(ch) +} + +func calculateUsersHash(users []*AccessUser) string { + js, _ := json.Marshal(&users) + hash := sha256.Sum256(js) + return hex.EncodeToString(hash[:]) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d4515a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/TheLab.ms/fobsvr + +go 1.21.1 + +require ( + github.com/Nerzal/gocloak/v13 v13.9.0 + github.com/julienschmidt/httprouter v1.3.0 +) + +require ( + github.com/go-resty/resty/v2 v2.7.0 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect + golang.org/x/net v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b925989 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/Nerzal/gocloak/v13 v13.9.0 h1:YWsJsdM5b0yhM2Ba3MLydiOlujkBry4TtdzfIzSVZhw= +github.com/Nerzal/gocloak/v13 v13.9.0/go.mod h1:YYuDcXZ7K2zKECyVP7pPqjKxx2AzYSpKDj8d6GuyM10= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/keycloak.go b/keycloak.go new file mode 100644 index 0000000..69a9a3b --- /dev/null +++ b/keycloak.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + "sync" + "time" + + "github.com/Nerzal/gocloak/v13" +) + +type keycloak struct { + client *gocloak.GoCloak + realm, baseURL, groupID string + + // use ensureToken to access these + tokenLock sync.Mutex + token *gocloak.JWT + tokenFetchTime time.Time +} + +func newKeycloak(url, groupID string) *keycloak { + return &keycloak{client: gocloak.NewClient(url), realm: "master", baseURL: url, groupID: groupID} +} + +func (k *keycloak) ListUsers(ctx context.Context) ([]*AccessUser, error) { + token, err := k.ensureToken(ctx) + if err != nil { + return nil, fmt.Errorf("getting token: %w", err) + } + + var ( + max = 50 + first = 0 + all = []*AccessUser{} + ) + for { + params, err := gocloak.GetQueryParams(gocloak.GetUsersParams{ + Max: &max, + First: &first, + }) + if err != nil { + return nil, err + } + + // Unfortunately the keycloak client doesn't support the group membership endpoint. + // We reuse the client's transport here while specifying our own URL. + var users []*gocloak.User + _, err = k.client.GetRequestWithBearerAuth(ctx, token.AccessToken). + SetResult(&users). + SetQueryParams(params). + Get(fmt.Sprintf("%s/admin/realms/%s/groups/%s/members", k.baseURL, k.realm, k.groupID)) + if err != nil { + return nil, err + } + if len(users) == 0 { + break + } + first += len(users) + + for _, user := range users { + u := newAccessUser(user) + if u == nil { + continue // invalid user (should be impossible) + } + all = append(all, u) + } + } + + return all, nil +} + +func (k *keycloak) EnsureWebhook(ctx context.Context, callbackURL string) error { + hooks, err := k.ListWebhooks(ctx) + if err != nil { + return fmt.Errorf("listing: %w", err) + } + + url := fmt.Sprintf("%s/webhook", callbackURL) + for _, hook := range hooks { + if hook.URL == url { + return nil // already exists + } + } + + return k.CreateWebhook(ctx, &Webhook{ + Enabled: true, + URL: url, + EventTypes: []string{"admin.*"}, + }) +} + +func (k *keycloak) ListWebhooks(ctx context.Context) ([]*Webhook, error) { + token, err := k.ensureToken(ctx) + if err != nil { + return nil, fmt.Errorf("getting token: %w", err) + } + + webhooks := []*Webhook{} + _, err = k.client.GetRequestWithBearerAuth(ctx, token.AccessToken). + SetResult(&webhooks). + Get(fmt.Sprintf("%s/realms/%s/webhooks", k.baseURL, k.realm)) + if err != nil { + return nil, err + } + + return webhooks, nil +} + +func (k *keycloak) CreateWebhook(ctx context.Context, webhook *Webhook) error { + token, err := k.ensureToken(ctx) + if err != nil { + return fmt.Errorf("getting token: %w", err) + } + + _, err = k.client.GetRequestWithBearerAuth(ctx, token.AccessToken). + SetBody(webhook). + Post(fmt.Sprintf("%s/realms/%s/webhooks", k.baseURL, k.realm)) + if err != nil { + return err + } + + return nil +} + +// For whatever reason the Keycloak client doesn't support token rotation +func (k *keycloak) ensureToken(ctx context.Context) (*gocloak.JWT, error) { + k.tokenLock.Lock() + defer k.tokenLock.Unlock() + + if k.token != nil && time.Since(k.tokenFetchTime) < (time.Duration(k.token.ExpiresIn)*time.Second)/2 { + return k.token, nil + } + + clientID, err := os.ReadFile("/var/lib/keycloak/client-id") + if err != nil { + return nil, fmt.Errorf("reading client id: %w", err) + } + clientSecret, err := os.ReadFile("/var/lib/keycloak/client-secret") + if err != nil { + return nil, fmt.Errorf("reading client secret: %w", err) + } + + token, err := k.client.LoginClient(ctx, string(clientID), string(clientSecret), k.realm) + if err != nil { + return nil, err + } + k.token = token + k.tokenFetchTime = time.Now() + + log.Printf("fetched new auth token from keycloak - will expire in %d seconds", k.token.ExpiresIn) + return k.token, nil +} + +type AccessUser struct { + UserID string `json:"userID"` + FobID int `json:"fobID"` + TTL int64 `json:"ttl"` +} + +func newAccessUser(kcuser *gocloak.User) *AccessUser { + if kcuser.ID == nil || kcuser.Attributes == nil { + return nil + } + + attr := *kcuser.Attributes + fobID, _ := strconv.Atoi(firstElOrZeroVal(attr["keyfobID"])) + if fobID == 0 { + return nil + } + if firstElOrZeroVal(attr["buildingAccessApprover"]) == "" { + return nil // no access for accounts that haven't explicitly been granted building access + } + + return &AccessUser{ + UserID: *kcuser.ID, + FobID: fobID, + TTL: (time.Hour * 24).Milliseconds(), // TODO: Load from keycloak + } +} + +type Webhook struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + URL string `json:"url"` + EventTypes []string `json:"eventTypes"` +} + +func firstElOrZeroVal[T any](slice []T) (val T) { + if len(slice) == 0 { + return val + } + return slice[0] +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..052adc5 --- /dev/null +++ b/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "log/slog" + "net/http" + "time" + + "github.com/julienschmidt/httprouter" +) + +func main() { + var ( + callbackURL = flag.String("callback-url", "", "URL at which Keycloak can reach this service") + resync = flag.Duration("resync-interval", time.Hour, "How often to resync if no webhook has been received") + keycloakURL = flag.String("keycloak-url", "", "Base URL of Keycloak") + keycloakGroupID = flag.String("keycloak-group-id", "", "UUID of the trusted Keycloak group") + ) + flag.Parse() + + k := newKeycloak(*keycloakURL, *keycloakGroupID) + if *callbackURL != "" { + err := k.EnsureWebhook(context.Background(), *callbackURL) + if err != nil { + panic(err) + } + } + + router := httprouter.New() + router.GET("/healthz", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + w.WriteHeader(204) + }) + + cache := newCache(k) + router.GET("/v1/fobs", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + if wait := r.URL.Query().Get("wait"); wait != "" { + waitDuration, err := time.ParseDuration(wait) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + cache.Wait(waitDuration) + } + + users, hash := cache.Load() + if users == nil { + w.WriteHeader(412) + return + } + if hash != "" && hash == r.Header.Get("If-None-Match") { + w.WriteHeader(304) + return + } + + w.Header().Set("ETag", hash) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&users) + }) + + router.POST("/v1/events", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + e := &Event{} + err := json.NewDecoder(r.Body).Decode(e) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + + // TODO: Actually store these somewhere + slog.Info("received event", "timestamp", e.Timestamp, "personID", e.PersonID, "fobID", e.FobID, "authorized", e.Authorized) + }) + + refresh := make(chan struct{}, 1) + router.POST("/webhook", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + slog.Info("received webhook") + select { + case refresh <- struct{}{}: + default: + } + }) + + // Sync periodically + go func() { + for range time.NewTicker(*resync).C { + select { + case refresh <- struct{}{}: + default: + } + } + }() + + // Keycloak loop + go func() { + var lastRetry time.Duration + for range refresh { + start: + err := cache.Fill() + if err != nil { + log.Printf("sync error: %s", err) + } else { + lastRetry = 0 + } + + if lastRetry == 0 { + lastRetry = time.Millisecond * 250 + } + lastRetry += lastRetry / 2 + if lastRetry > *resync { + lastRetry = *resync + } + time.Sleep(lastRetry) + goto start + } + }() + + panic(http.ListenAndServe(":8080", router)) +} + +type Event struct { + Timestamp int64 `json:"timestamp"` + PersonID string `json:"personID"` + FobID int64 `json:"fobID"` + Authorized bool `json:"authorized"` +}