Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jveski committed Mar 4, 2024
1 parent a030413 commit f5183d9
Show file tree
Hide file tree
Showing 7 changed files with 480 additions and 0 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
87 changes: 87 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
@@ -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[:])
}
17 changes: 17 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
26 changes: 26 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
197 changes: 197 additions & 0 deletions keycloak.go
Original file line number Diff line number Diff line change
@@ -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]
}
Loading

0 comments on commit f5183d9

Please sign in to comment.