-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
480 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[:]) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
} |
Oops, something went wrong.