diff --git a/.github/workflows/test-spnego.yml b/.github/workflows/test-spnego.yml new file mode 100644 index 000000000..97d030912 --- /dev/null +++ b/.github/workflows/test-spnego.yml @@ -0,0 +1,34 @@ +name: "Test spnego" + +on: + push: + branches: + - master + - main + paths: + - 'v3/spnego/**/*.go' + - 'v3/spnego/go.mod' + - 'v3/spnego/go.sum' + pull_request: + paths: + - 'v3/spnego/**/*.go' + - 'v3/spnego/go.mod' + - 'v3/spnego/go.sum' + +jobs: + Tests: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: + - 1.25.x + steps: + - name: Fetch Repository + uses: actions/checkout@v6 + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: '${{ matrix.go-version }}' + - name: Run Test + working-directory: ./v3/spnego + run: go test -v -race ./... diff --git a/go.work b/go.work index 795d4febf..55ea96cb4 100644 --- a/go.work +++ b/go.work @@ -14,6 +14,7 @@ use ( ./v3/otel ./v3/paseto ./v3/sentry + ./v3/spnego ./v3/socketio ./v3/swaggerui ./v3/swaggo diff --git a/v3/spnego/README.md b/v3/spnego/README.md new file mode 100644 index 000000000..43825f1ae --- /dev/null +++ b/v3/spnego/README.md @@ -0,0 +1,148 @@ +--- +id: spnego +--- + +# SPNEGO Kerberos Authentication Middleware for Fiber + +![Release](https://img.shields.io/github/v/tag/gofiber/contrib?filter=spnego*) +[![Discord](https://img.shields.io/discord/704680098577514527?style=flat&label=%F0%9F%92%AC%20discord&color=00ACD7)](https://gofiber.io/discord) +![Test](https://github.com/gofiber/contrib/workflows/Test%20spnego/badge.svg) + +This middleware provides SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) authentication for [Fiber](https://github.com/gofiber/fiber) applications, enabling Kerberos authentication for HTTP requests and inspired by [gokrb5](https://github.com/jcmturner/gokrb5) + +## Features + +- Kerberos authentication via SPNEGO mechanism +- Flexible keytab lookup system +- Support for dynamic keytab retrieval from various sources +- Integration with Fiber context for authenticated identity storage +- Configurable logging + +## Version Compatibility + +This middleware is compatible with: + +- **Fiber v3** + +## Installation + +```bash +# For Fiber v3 +go get github.com/gofiber/contrib/v3/spnego +``` + +## Usage + +```go +package main + +import ( + "fmt" + "time" + + "github.com/gofiber/contrib/v3/spnego" + "github.com/gofiber/contrib/v3/spnego/utils" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/log" +) + +func main() { + app := fiber.New() + // Create a configuration with a keytab lookup function + // For testing, you can create a mock keytab file using utils.NewMockKeytab + // In production, use a real keytab file + _, clean, err := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso1.example.com"), + utils.WithRealm("EXAMPLE.LOCAL"), + utils.WithFilename("./temp-sso1.keytab"), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: time.Now(), + }), + ) + if err != nil { + log.Fatalf("Failed to create mock keytab: %v", err) + } + defer clean() + keytabLookup, err := spnego.NewKeytabFileLookupFunc("./temp-sso1.keytab") + if err != nil { + log.Fatalf("Failed to create keytab lookup function: %v", err) + } + + // Create the middleware + authMiddleware, err := spnego.New(spnego.Config{ + KeytabLookup: keytabLookup, + }) + if err != nil { + log.Fatalf("Failed to create middleware: %v", err) + } + + // Apply the middleware to protected routes + app.Use("/protected", authMiddleware) + + // Access authenticated identity + app.Get("/protected/resource", func(c fiber.Ctx) error { + identity, ok := spnego.GetAuthenticatedIdentityFromContext(c) + if !ok { + return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized") + } + return c.SendString(fmt.Sprintf("Hello, %s!", identity.UserName())) + }) + + log.Info("Server is running on :3000") + app.Listen(":3000") +} +``` + +## Dynamic Keytab Lookup + +The middleware is designed with extensibility in mind, allowing keytab retrieval from various sources beyond static files: + +```go +// Example: Retrieve keytab from a database +func dbKeytabLookup() (*keytab.Keytab, error) { + // Your database lookup logic here + // ... + return keytabFromDatabase, nil +} + +// Example: Retrieve keytab from a remote service +func remoteKeytabLookup() (*keytab.Keytab, error) { + // Your remote service call logic here + // ... + return keytabFromRemote, nil +} +``` + +## API Reference + +### `New(cfg Config) (fiber.Handler, error)` + +Creates a new SPNEGO authentication middleware. + +### `GetAuthenticatedIdentityFromContext(ctx fiber.Ctx) (goidentity.Identity, bool)` + +Retrieves the authenticated identity from the Fiber context. + +### `NewKeytabFileLookupFunc(keytabFiles ...string) (KeytabLookupFunc, error)` + +Creates a new KeytabLookupFunc that loads keytab files. + +## Configuration + +The `Config` struct supports the following fields: + +- `KeytabLookup`: A function that retrieves the keytab (required) +- `Log`: The logger used for middleware logging (optional, defaults to Fiber's default logger) + +## Requirements + +- Fiber v3 +- Kerberos infrastructure + +## Notes + +- Ensure your Kerberos infrastructure is properly configured +- The middleware handles the SPNEGO negotiation process +- Authenticated identities are stored in the Fiber context using `contextKeyOfIdentity` diff --git a/v3/spnego/config.go b/v3/spnego/config.go new file mode 100644 index 000000000..230a89cd2 --- /dev/null +++ b/v3/spnego/config.go @@ -0,0 +1,47 @@ +package spnego + +import ( + "fmt" + "log" + + "github.com/jcmturner/gokrb5/v8/keytab" +) + +type contextKey string + +// contextKeyOfIdentity is the key used to store the authenticated identity in the Fiber context +const contextKeyOfIdentity contextKey = "middleware.spnego.Identity" + +// KeytabLookupFunc is a function type that returns a keytab or an error +// It's used to look up the keytab dynamically when needed +// This design allows for extensibility, enabling keytab retrieval from various sources +// such as databases, remote services, or other custom implementations beyond static files +type KeytabLookupFunc func() (*keytab.Keytab, error) + +// Config holds the configuration for the SPNEGO middleware +// It includes the keytab lookup function and a logger +type Config struct { + // KeytabLookup is a function that retrieves the keytab + KeytabLookup KeytabLookupFunc + // Log is the logger used for middleware logging + Log *log.Logger +} + +// NewKeytabFileLookupFunc creates a new KeytabLookupFunc that loads keytab files +// It accepts one or more keytab file paths and returns a function that loads them +func NewKeytabFileLookupFunc(keytabFiles ...string) (KeytabLookupFunc, error) { + if len(keytabFiles) == 0 { + return nil, ErrConfigInvalidOfAtLeastOneKeytabFileRequired + } + return func() (*keytab.Keytab, error) { + var mergeKeytab keytab.Keytab + for _, keytabFile := range keytabFiles { + kt, err := keytab.Load(keytabFile) + if err != nil { + return nil, fmt.Errorf("%w: file %s load failed: %w", ErrLoadKeytabFileFailed, keytabFile, err) + } + mergeKeytab.Entries = append(mergeKeytab.Entries, kt.Entries...) + } + return &mergeKeytab, nil + }, nil +} diff --git a/v3/spnego/config_test.go b/v3/spnego/config_test.go new file mode 100644 index 000000000..1eb6f729d --- /dev/null +++ b/v3/spnego/config_test.go @@ -0,0 +1,132 @@ +package spnego + +import ( + "os" + "testing" + "time" + + "github.com/gofiber/contrib/v3/spnego/utils" + "github.com/stretchr/testify/require" +) + +func TestNewKeytabFileLookupFunc(t *testing.T) { + t.Run("test didn't give any keytab files", func(t *testing.T) { + _, err := NewKeytabFileLookupFunc() + require.ErrorIs(t, err, ErrConfigInvalidOfAtLeastOneKeytabFileRequired) + }) + t.Run("test not found keytab file", func(t *testing.T) { + err := os.WriteFile("./invalid.keytab", []byte("12345"), 0600) + require.NoError(t, err) + t.Cleanup(func() { + os.Remove("./invalid.keytab") + }) + fn, err := NewKeytabFileLookupFunc("./invalid.keytab") + require.NoError(t, err) + _, err = fn() + require.ErrorIs(t, err, ErrLoadKeytabFileFailed) + }) + t.Run("test one keytab file", func(t *testing.T) { + tm := time.Now() + _, clean, err := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso.example.com"), + utils.WithRealm("TEST.LOCAL"), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm, + }), + utils.WithFilename("./temp.keytab"), + ) + require.NoError(t, err) + t.Cleanup(clean) + fn, err := NewKeytabFileLookupFunc("./temp.keytab") + require.NoError(t, err) + kt1, err := fn() + require.NoError(t, err) + info := utils.GetKeytabInfo(kt1) + require.Len(t, info, 1) + require.Equal(t, info[0].PrincipalName, "HTTP/sso.example.com@TEST.LOCAL") + require.Equal(t, info[0].Realm, "TEST.LOCAL") + require.Len(t, info[0].Pairs, 1) + require.Equal(t, info[0].Pairs[0].Version, uint8(2)) + require.Equal(t, info[0].Pairs[0].EncryptType, int32(18)) + // Note: The creation time of keytab is only accurate to the second. + require.Equal(t, info[0].Pairs[0].CreateTime.Unix(), tm.Unix()) + }) + t.Run("test multiple keytab file but has invalid keytab", func(t *testing.T) { + tm := time.Now() + _, clean, err := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso.example.com"), + utils.WithRealm("TEST.LOCAL"), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm, + }), + utils.WithFilename("./temp.keytab"), + ) + require.NoError(t, err) + t.Cleanup(clean) + err = os.WriteFile("./invalid1.keytab", []byte("12345"), 0600) + require.NoError(t, err) + t.Cleanup(func() { + os.Remove("./invalid1.keytab") + }) + fn, err := NewKeytabFileLookupFunc("./temp.keytab", "./invalid1.keytab") + require.NoError(t, err) + _, err = fn() + require.ErrorIs(t, err, ErrLoadKeytabFileFailed) + }) + t.Run("test multiple keytab file", func(t *testing.T) { + tm := time.Now() + _, clean1, err1 := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso.example1.com"), + utils.WithRealm("TEST.LOCAL"), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm, + }), + utils.WithFilename("./temp1.keytab"), + ) + require.NoError(t, err1) + t.Cleanup(clean1) + _, clean2, err2 := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso.example2.com"), + utils.WithRealm("TEST.LOCAL"), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 17, + CreateTime: tm, + }, utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm, + }), + utils.WithFilename("./temp2.keytab"), + ) + require.NoError(t, err2) + t.Cleanup(clean2) + fn, err := NewKeytabFileLookupFunc("./temp1.keytab", "./temp2.keytab") + require.NoError(t, err) + kt2, err := fn() + require.NoError(t, err) + info := utils.GetKeytabInfo(kt2) + require.Len(t, info, 2) + require.Equal(t, info[0].PrincipalName, "HTTP/sso.example1.com@TEST.LOCAL") + require.Equal(t, info[0].Realm, "TEST.LOCAL") + require.Len(t, info[0].Pairs, 1) + require.Equal(t, info[0].Pairs[0].Version, uint8(2)) + require.Equal(t, info[0].Pairs[0].EncryptType, int32(18)) + require.Equal(t, info[0].Pairs[0].CreateTime.Unix(), tm.Unix()) + require.Equal(t, info[1].PrincipalName, "HTTP/sso.example2.com@TEST.LOCAL") + require.Equal(t, info[1].Realm, "TEST.LOCAL") + require.Len(t, info[1].Pairs, 2) + require.Equal(t, info[1].Pairs[0].Version, uint8(2)) + require.Equal(t, info[1].Pairs[0].EncryptType, int32(17)) + require.Equal(t, info[1].Pairs[0].CreateTime.Unix(), tm.Unix()) + require.Equal(t, info[1].Pairs[1].Version, uint8(2)) + require.Equal(t, info[1].Pairs[1].EncryptType, int32(18)) + require.Equal(t, info[1].Pairs[1].CreateTime.Unix(), tm.Unix()) + }) +} diff --git a/v3/spnego/doc.go b/v3/spnego/doc.go new file mode 100644 index 000000000..ea28c342c --- /dev/null +++ b/v3/spnego/doc.go @@ -0,0 +1,4 @@ +// Package spnego provides SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) +// authentication middleware for Fiber applications. +// It enables Kerberos authentication for HTTP requests. +package spnego diff --git a/v3/spnego/error.go b/v3/spnego/error.go new file mode 100644 index 000000000..4d05705bf --- /dev/null +++ b/v3/spnego/error.go @@ -0,0 +1,18 @@ +package spnego + +import "errors" + +// ErrConfigInvalidOfKeytabLookupFunctionRequired is returned when the KeytabLookup function is not set in Config +var ErrConfigInvalidOfKeytabLookupFunctionRequired = errors.New("config invalid: keytab lookup function is required") + +// ErrLookupKeytabFailed is returned when the keytab lookup fails +var ErrLookupKeytabFailed = errors.New("keytab lookup failed") + +// ErrConvertRequestFailed is returned when the request conversion to HTTP request fails +var ErrConvertRequestFailed = errors.New("convert request failed") + +// ErrConfigInvalidOfAtLeastOneKeytabFileRequired is returned when no keytab files are provided +var ErrConfigInvalidOfAtLeastOneKeytabFileRequired = errors.New("config invalid: at least one keytab file required") + +// ErrLoadKeytabFileFailed is returned when load keytab files failed +var ErrLoadKeytabFileFailed = errors.New("load keytab failed") diff --git a/v3/spnego/example/example.go b/v3/spnego/example/example.go new file mode 100644 index 000000000..d17eced56 --- /dev/null +++ b/v3/spnego/example/example.go @@ -0,0 +1,67 @@ +package example + +import ( + "fmt" + "time" + + "github.com/gofiber/contrib/v3/spnego" + "github.com/gofiber/contrib/v3/spnego/utils" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/log" +) + +func ExampleNew() { + app := fiber.New() + // create mock keytab file + // you must use a real keytab file + _, clean, err := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso1.example.com"), + utils.WithRealm("EXAMPLE.LOCAL"), + utils.WithFilename("./temp-sso1.keytab"), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: time.Now(), + }), + ) + if err != nil { + log.Fatalf("create mock keytab error: %v", err) + } + defer clean() + keytabLookup, err := spnego.NewKeytabFileLookupFunc("./temp-sso1.keytab") + if err != nil { + panic(fmt.Errorf("create keytab lookup function failed: %w", err)) + } + authMiddleware, err := spnego.New(spnego.Config{ + KeytabLookup: keytabLookup, + }) + if err != nil { + panic(fmt.Errorf("create spnego middleware failed: %w", err)) + } + // Apply the middleware to protected routes + app.Use("/protected", authMiddleware) + + // Access authenticated identity + app.Get("/protected/resource", func(c fiber.Ctx) error { + identity, ok := spnego.GetAuthenticatedIdentityFromContext(c) + if !ok { + return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized") + } + return c.SendString(fmt.Sprintf("Hello, %s!", identity.UserName())) + }) + log.Info("Server is running on sso.example.local:3000") + go func() { + <-time.After(time.Second * 1) + fmt.Println("use curl -kv --negotiate http://sso.example.local:3000/protected/resource") + fmt.Println("Note: In /etc/hosts, sso.example.local must be bound to a LAN address; 127.0.0.1 won't work.") + fmt.Println("if response is 401, execute `klist` to check your Kerberos session") + <-time.After(time.Second * 2) + fmt.Println("close server") + if err = app.Shutdown(); err != nil { + panic(fmt.Errorf("shutdown server failed: %w", err)) + } + }() + if err := app.Listen("sso.example.local:3000"); err != nil { + panic(fmt.Errorf("start server failed: %w", err)) + } +} diff --git a/v3/spnego/go.mod b/v3/spnego/go.mod new file mode 100644 index 000000000..a1732f021 --- /dev/null +++ b/v3/spnego/go.mod @@ -0,0 +1,36 @@ +module github.com/gofiber/contrib/v3/spnego + +go 1.25.0 + +require ( + github.com/gofiber/fiber/v3 v3.0.0-rc.2 + github.com/jcmturner/goidentity/v6 v6.0.1 + github.com/jcmturner/gokrb5/v8 v8.4.4 + github.com/stretchr/testify v1.11.1 + github.com/valyala/fasthttp v1.65.0 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tinylib/msgp v1.4.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/v3/spnego/go.sum b/v3/spnego/go.sum new file mode 100644 index 000000000..8dbcbeb4e --- /dev/null +++ b/v3/spnego/go.sum @@ -0,0 +1,110 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gofiber/fiber/v3 v3.0.0-rc.2 h1:5I3RQ7XygDBfWRlMhkATjyJKupMmfMAVmnsrgo6wmc0= +github.com/gofiber/fiber/v3 v3.0.0-rc.2/go.mod h1:EHKwhVCONMruJTOmvSPSy0CdACJ3uqCY8vGaBXft8yg= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s= +github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shamaton/msgpack/v2 v2.3.1 h1:R3QNLIGA/tbdczNMZ5PCRxrXvy+fnzsIaHG4kKMgWYo= +github.com/shamaton/msgpack/v2 v2.3.1/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= +github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/v3/spnego/identity.go b/v3/spnego/identity.go new file mode 100644 index 000000000..f3c6c18b0 --- /dev/null +++ b/v3/spnego/identity.go @@ -0,0 +1,28 @@ +package spnego + +import "github.com/jcmturner/goidentity/v6" + +type FiberContext interface { + Locals(key any, value ...any) any +} + +// SetAuthenticatedIdentityToContext stores the authenticated identity in the Fiber context. +// It takes a Fiber context and an identity, and sets it using the contextKeyOfIdentity key +// for later retrieval by other handlers in the request chain. +func SetAuthenticatedIdentityToContext[T FiberContext](ctx T, identity goidentity.Identity) { + ctx.Locals(contextKeyOfIdentity, identity) +} + +// GetAuthenticatedIdentityFromContext retrieves the authenticated identity from the Fiber context. +// It returns the identity and a boolean indicating if it was found. +// This function should be used by subsequent handlers to access the authenticated user's information. +// Example: +// +// user, ok := GetAuthenticatedIdentityFromContext(ctx) +// if ok { +// fmt.Printf("Authenticated user: %s\n", user.UserName()) +// } +func GetAuthenticatedIdentityFromContext[T FiberContext](ctx T) (goidentity.Identity, bool) { + id, ok := ctx.Locals(contextKeyOfIdentity).(goidentity.Identity) + return id, ok +} diff --git a/v3/spnego/identity_test.go b/v3/spnego/identity_test.go new file mode 100644 index 000000000..a536a9369 --- /dev/null +++ b/v3/spnego/identity_test.go @@ -0,0 +1,38 @@ +package spnego + +import ( + "net/http/httptest" + "testing" + + fiberV3 "github.com/gofiber/fiber/v3" + "github.com/jcmturner/goidentity/v6" + "github.com/stretchr/testify/require" +) + +func TestGetAndSetAuthenticatedIdentityFromContextForFiberV3(t *testing.T) { + app := fiberV3.New() + id := goidentity.NewUser("test@TEST.LOCAL") + app.Use("/identity", func(ctx fiberV3.Ctx) error { + SetAuthenticatedIdentityToContext(ctx, &id) + return ctx.Next() + }) + app.Get("/test", func(ctx fiberV3.Ctx) error { + _, ok := GetAuthenticatedIdentityFromContext(ctx) + require.False(t, ok) + return ctx.SendStatus(fiberV3.StatusOK) + }) + app.Get("/identity/test", func(ctx fiberV3.Ctx) error { + user, ok := GetAuthenticatedIdentityFromContext(ctx) + require.True(t, ok) + require.Equal(t, id.UserName(), user.UserName()) + require.Equal(t, id.Domain(), user.Domain()) + return ctx.SendStatus(fiberV3.StatusOK) + }) + resp, err := app.Test(httptest.NewRequest("GET", "/test", nil)) + require.NoError(t, err) + require.Equal(t, fiberV3.StatusOK, resp.StatusCode) + + resp, err = app.Test(httptest.NewRequest("GET", "/identity/test", nil)) + require.NoError(t, err) + require.Equal(t, fiberV3.StatusOK, resp.StatusCode) +} diff --git a/v3/spnego/spnego.go b/v3/spnego/spnego.go new file mode 100644 index 000000000..99507684f --- /dev/null +++ b/v3/spnego/spnego.go @@ -0,0 +1,52 @@ +package spnego + +import ( + "fmt" + "log" + "net/http" + + "github.com/gofiber/fiber/v3" + flog "github.com/gofiber/fiber/v3/log" + "github.com/gofiber/fiber/v3/middleware/adaptor" + "github.com/jcmturner/goidentity/v6" + "github.com/jcmturner/gokrb5/v8/service" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +// New creates a new SPNEGO authentication middleware. +// It takes a Config struct and returns a Fiber handler or an error. +// The middleware handles Kerberos authentication for incoming requests using the +// SPNEGO protocol, verifying client credentials against the configured keytab. +func New(cfg Config) (fiber.Handler, error) { + // Validate configuration + if cfg.KeytabLookup == nil { + return nil, ErrConfigInvalidOfKeytabLookupFunctionRequired + } + // Set default logger if fiber log using *log.Logger + var opts = make([]func(settings *service.Settings), 0, 1) + if cfg.Log != nil { + opts = append(opts, service.Logger(cfg.Log)) + } else if l := flog.DefaultLogger[*log.Logger]().Logger(); l != nil { + opts = append(opts, service.Logger(l)) + } + // Return the middleware handler + return func(ctx fiber.Ctx) error { + // Look up the keytab + kt, err := cfg.KeytabLookup() + if err != nil { + return fmt.Errorf("%w: %v", ErrLookupKeytabFailed, err) + } + // Create the SPNEGO handler using the keytab + var handleErr error + handler := adaptor.HTTPHandler(spnego.SPNEGOKRB5Authenticate(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + // Set the authenticated identity in the Fiber context + SetAuthenticatedIdentityToContext(ctx, goidentity.FromHTTPRequestContext(r)) + // Call the next handler in the chain + handleErr = ctx.Next() + }), kt, opts...)) + if err = handler(ctx); err != nil { + return err + } + return handleErr + }, nil +} diff --git a/v3/spnego/spnego_test.go b/v3/spnego/spnego_test.go new file mode 100644 index 000000000..42e1728c0 --- /dev/null +++ b/v3/spnego/spnego_test.go @@ -0,0 +1,91 @@ +package spnego + +import ( + "errors" + "fmt" + "net/http" + "path" + "testing" + "time" + + "github.com/gofiber/contrib/v3/spnego/utils" + "github.com/gofiber/fiber/v3" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" +) + +func TestNewSpnegoKrb5AuthenticateMiddleware(t *testing.T) { + t.Run("test for keytab lookup function not set", func(t *testing.T) { + _, err := New(Config{}) + require.ErrorIs(t, err, ErrConfigInvalidOfKeytabLookupFunctionRequired) + }) + t.Run("test for keytab lookup failed", func(t *testing.T) { + middleware, err := New(Config{ + KeytabLookup: func() (*keytab.Keytab, error) { + return nil, errors.New("mock keytab lookup error") + }, + }) + require.NoError(t, err) + app := fiber.New() + app.Get("/authenticate", middleware, func(c fiber.Ctx) error { + return c.SendString("authenticated") + }) + handler := app.Handler() + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(fiber.MethodGet) + ctx.Request.SetRequestURI("/authenticate") + handler(ctx) + require.Equal(t, http.StatusInternalServerError, ctx.Response.StatusCode()) + require.Equal(t, fmt.Sprintf("%s: mock keytab lookup error", ErrLookupKeytabFailed), string(ctx.Response.Body())) + }) + t.Run("test for keytab lookup function is set", func(t *testing.T) { + tm := time.Now() + filename1 := path.Join(t.TempDir(), "temp-sso1.keytab") + filename2 := path.Join(t.TempDir(), "temp-sso2.keytab") + _, clean1, err1 := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso1.example.com"), + utils.WithRealm("EXAMPLE.LOCAL"), + utils.WithFilename(filename1), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm, + }), + ) + require.NoError(t, err1) + t.Cleanup(clean1) + _, clean2, err2 := utils.NewMockKeytab( + utils.WithPrincipal("HTTP/sso2.example.com"), + utils.WithRealm("EXAMPLE.LOCAL"), + utils.WithFilename(filename2), + utils.WithPairs(utils.EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm, + }), + ) + require.NoError(t, err2) + t.Cleanup(clean2) + lookupFunc, err := NewKeytabFileLookupFunc(filename1, filename2) + require.NoError(t, err) + middleware, err := New(Config{ + KeytabLookup: lookupFunc, + }) + require.NoError(t, err) + app := fiber.New() + app.Get("/authenticate", middleware, func(c fiber.Ctx) error { + user, ok := GetAuthenticatedIdentityFromContext(c) + if ok { + t.Logf("username: %s\ndomain: %s\n", user.UserName(), user.Domain()) + } + return c.SendString("authenticated") + }) + handler := app.Handler() + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(fiber.MethodGet) + ctx.Request.SetRequestURI("/authenticate") + handler(ctx) + require.Equal(t, fasthttp.StatusUnauthorized, ctx.Response.StatusCode()) + }) +} diff --git a/v3/spnego/utils/keytab.go b/v3/spnego/utils/keytab.go new file mode 100644 index 000000000..2297a0591 --- /dev/null +++ b/v3/spnego/utils/keytab.go @@ -0,0 +1,80 @@ +package utils + +import ( + "time" + + "maps" + "sort" + + "github.com/jcmturner/gokrb5/v8/keytab" +) + +// KeytabInfo represents information about a principal in a Kerberos keytab +// It contains the principal name, realm, and associated encryption type pairs +type KeytabInfo struct { + PrincipalName string // The Kerberos principal name (e.g., HTTP/service.example.com) + Realm string // The Kerberos realm (e.g., EXAMPLE.COM) + Pairs []EncryptTypePair // List of encryption type pairs for this principal +} + +// EncryptTypePair represents an encryption type entry in a Kerberos keytab +// It contains the version, encryption type, and creation timestamp +type EncryptTypePair struct { + Version uint8 // The key version number + EncryptType int32 // The encryption type (e.g., 18 for AES-256-CTS-HMAC-SHA1-96) + CreateTime time.Time // The timestamp when this key was created +} + +// MultiKeytabInfo is a slice of KeytabInfo structures +// Used to represent multiple principal entries from a keytab +type MultiKeytabInfo []KeytabInfo + +// GetKeytabInfo extracts information from a Kerberos keytab and returns it in a structured format +// It organizes keytab entries by principal name and sorts them alphabetically +// +// Parameters: +// +// kt - A pointer to a keytab.Keytab instance (can be nil) +// +// Returns: +// +// MultiKeytabInfo - A sorted slice of KeytabInfo structures containing principal information +// +// Example usage: +// +// kt, _ := keytab.Load("/path/to/keytab") +// info := GetKeytabInfo(kt) +// for _, principal := range info { +// fmt.Printf("Principal: %s@%s\n", principal.PrincipalName, principal.Realm) +// for _, pair := range principal.Pairs { +// fmt.Printf(" EncryptType: %d, Version: %d, Created: %v\n", pair.EncryptType, pair.Version, pair.CreateTime) +// } +// } +func GetKeytabInfo(kt *keytab.Keytab) MultiKeytabInfo { + keytabMap := make(map[string]KeytabInfo) + if kt != nil { + for _, entry := range kt.Entries { + item, ok := keytabMap[entry.Principal.String()] + if !ok { + item = KeytabInfo{ + PrincipalName: entry.Principal.String(), + Realm: entry.Principal.Realm, + } + } + item.Pairs = append(item.Pairs, EncryptTypePair{ + Version: entry.KVNO8, + EncryptType: entry.Key.KeyType, + CreateTime: entry.Timestamp, + }) + keytabMap[entry.Principal.String()] = item + } + } + var mk = make(MultiKeytabInfo, 0, len(keytabMap)) + for _, item := range maps.Values(keytabMap) { + mk = append(mk, item) + } + sort.Slice(mk, func(i, j int) bool { + return mk[i].PrincipalName < mk[j].PrincipalName + }) + return mk +} diff --git a/v3/spnego/utils/keytab_test.go b/v3/spnego/utils/keytab_test.go new file mode 100644 index 000000000..7411c5c70 --- /dev/null +++ b/v3/spnego/utils/keytab_test.go @@ -0,0 +1,46 @@ +package utils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGetKeytabInfo(t *testing.T) { + tm := time.Now() + kt, _, err := NewMockKeytab( + WithRealm("EXAMPLE.LOCAL"), + WithPrincipal("HTTP/sso-test.example.com"), + WithPassword("abcd1234"), + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 17, + CreateTime: tm, + }, EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: tm, + }), + ) + require.NoError(t, err) + err = kt.AddEntry("HTTP/sso-test2.example.com", "EXAMPLE.LOCAL", "qwer1234", tm.Add(-time.Minute), 2, 18) + require.NoError(t, err) + info := GetKeytabInfo(kt) + require.Len(t, info, 2) + require.Equal(t, info[0].PrincipalName, "HTTP/sso-test.example.com@EXAMPLE.LOCAL") + require.Equal(t, info[0].Realm, "EXAMPLE.LOCAL") + require.Len(t, info[0].Pairs, 2) + require.Equal(t, info[0].Pairs[0].Version, uint8(3)) + require.Equal(t, info[0].Pairs[0].EncryptType, int32(17)) + require.Equal(t, info[0].Pairs[0].CreateTime.Unix(), tm.Unix()) + require.Equal(t, info[0].Pairs[1].Version, uint8(3)) + require.Equal(t, info[0].Pairs[1].EncryptType, int32(18)) + require.Equal(t, info[0].Pairs[1].CreateTime.Unix(), tm.Unix()) + require.Equal(t, info[1].PrincipalName, "HTTP/sso-test2.example.com@EXAMPLE.LOCAL") + require.Equal(t, info[1].Realm, "EXAMPLE.LOCAL") + require.Len(t, info[1].Pairs, 1) + require.Equal(t, info[1].Pairs[0].Version, uint8(2)) + require.Equal(t, info[1].Pairs[0].EncryptType, int32(18)) + require.Equal(t, info[1].Pairs[0].CreateTime.Unix(), tm.Add(-time.Minute).Unix()) +} diff --git a/v3/spnego/utils/mock_keytab.go b/v3/spnego/utils/mock_keytab.go new file mode 100644 index 000000000..6d1126e51 --- /dev/null +++ b/v3/spnego/utils/mock_keytab.go @@ -0,0 +1,157 @@ +package utils + +import ( + "fmt" + "os" + + "github.com/jcmturner/gokrb5/v8/keytab" +) + +// mockOptions contains configuration parameters for creating a mock keytab +// It allows customization of principal name, realm, password, filename, and encryption type pairs +// used for testing SPNEGO authentication middleware +type mockOptions struct { + PrincipalName string // Kerberos principal name + Realm string // Kerberos realm + Password string // Password for generating encryption keys + Filename string // Optional filename to write the mock keytab + Pairs []EncryptTypePair // Encryption type pairs to add to the keytab +} + +// apply applies the given options to the mockOptions +// This method iterates over all provided options and applies them in sequence +// allowing for flexible configuration of the mock keytab +func (m *mockOptions) apply(opts ...MockOption) { + for _, opt := range opts { + opt(m) + } +} + +// WithPrincipal sets the Kerberos principal name for the mock keytab +// Example: WithPrincipal("HTTP/service.example.com") +func WithPrincipal(principalName string) MockOption { + return func(options *mockOptions) { + options.PrincipalName = principalName + } +} + +// WithRealm sets the Kerberos realm for the mock keytab +// Example: WithRealm("EXAMPLE.COM") +func WithRealm(realm string) MockOption { + return func(options *mockOptions) { + options.Realm = realm + } +} + +// WithFilename specifies the filename to write the mock keytab to +// If provided, the keytab will be written to this file +// Example: WithFilename("test.keytab") +func WithFilename(filename string) MockOption { + return func(options *mockOptions) { + options.Filename = filename + } +} + +// WithPairs adds encryption type pairs to the mock keytab +// Each pair specifies an encryption type and associated parameters +// Example: WithPairs(EncryptTypePair{EncryptType: 18, CreateTime: time.Now(), Version: 1}) +func WithPairs(pairs ...EncryptTypePair) MockOption { + return func(options *mockOptions) { + options.Pairs = append(options.Pairs, pairs...) + } +} + +// WithPassword sets the password used to generate encryption keys +// This password is used with the principal name and realm to create keys +// Example: WithPassword("securePassword123") +func WithPassword(password string) MockOption { + return func(options *mockOptions) { + options.Password = password + } +} + +// MockOption defines a function type for configuring mockOptions +// Used to implement the option pattern for flexible configuration +type MockOption func(*mockOptions) + +// newDefaultMockOptions creates mockOptions with default values +// Default realm is "TEST.LOCAL" and default password is "abcdef" +// These defaults can be overridden using WithXXX option functions +func newDefaultMockOptions() *mockOptions { + return &mockOptions{ + Realm: "TEST.LOCAL", + Password: "abcdef", + } +} + +type fileOperator interface { + OpenFile(filename string, flag int, perm os.FileMode) (*os.File, error) + Remove(filename string) error +} + +type myFileOperator struct{} + +func (m myFileOperator) OpenFile(filename string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(filename, flag, perm) +} + +func (m myFileOperator) Remove(filename string) error { + return os.Remove(filename) +} + +var defaultFileOperator fileOperator = myFileOperator{} + +// NewMockKeytab creates a mock keytab for testing purposes +// It allows customization through option functions and returns: +// - A keytab.Keytab instance populated with the specified entries +// - A cleanup function that removes any created files +// - An error if the keytab creation fails +// +// Example usage: +// +// kt, cleanup, err := NewMockKeytab( +// WithPrincipal("HTTP/service.example.com"), +// WithRealm("EXAMPLE.COM"), +// WithPassword("secret"), +// WithFilename("test.keytab"), +// WithPairs(EncryptTypePair{EncryptType: 18}) +// ) +// defer cleanup() +// if err != nil { +// // handle error +// } +func NewMockKeytab(opts ...MockOption) (*keytab.Keytab, func(), error) { + opt := newDefaultMockOptions() + opt.apply(opts...) + kt := keytab.New() + var err error + for _, pair := range opt.Pairs { + if err = kt.AddEntry(opt.PrincipalName, opt.Realm, opt.Password, pair.CreateTime, pair.Version, pair.EncryptType); err != nil { + return nil, nil, fmt.Errorf("error adding entry: %w", err) + } + } + var clean = func() {} + if len(opt.Filename) > 0 { + file, err := defaultFileOperator.OpenFile(opt.Filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o666) + if err != nil { + return nil, nil, fmt.Errorf("error opening file: %w", err) + } + clean = func() { + _ = defaultFileOperator.Remove(opt.Filename) + } + if _, err = kt.Write(file); err != nil { + if err = file.Close(); err != nil { + clean() + return nil, nil, fmt.Errorf("error closing file: %w", err) + } + clean() + return nil, nil, fmt.Errorf("error writing to file: %w", err) + } + if err = file.Close(); err != nil { + clean() + return nil, nil, fmt.Errorf("error closing file: %w", err) + } + return kt, clean, nil + } + return kt, clean, nil +} diff --git a/v3/spnego/utils/mock_keytab_test.go b/v3/spnego/utils/mock_keytab_test.go new file mode 100644 index 000000000..2a7b00d15 --- /dev/null +++ b/v3/spnego/utils/mock_keytab_test.go @@ -0,0 +1,194 @@ +package utils + +import ( + "os" + "path" + "testing" + "time" + + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/types" + "github.com/stretchr/testify/require" +) + +type mockFileOperator struct { + flag int +} + +func (m mockFileOperator) OpenFile(filename string, flag int, perm os.FileMode) (*os.File, error) { + if m.flag&0x01 != 0 { + return nil, os.ErrPermission + } + file, err := os.OpenFile(filename, flag, perm) + if err != nil { + return nil, err + } + if m.flag&0x02 != 0 { + file.Close() + } + return file, nil +} + +func (m mockFileOperator) Remove(filename string) error { + return os.Remove(filename) +} + +func TestNewMockKeytab(t *testing.T) { + t.Run("test add keytab entry failed", func(t *testing.T) { + _, _, err := NewMockKeytab( + WithPrincipal("HTTP/sso.example.com"), + WithRealm("TEST.LOCAL"), + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: time.Now(), + }, EncryptTypePair{ + Version: 3, + EncryptType: 0xffff, + CreateTime: time.Now(), + }), + ) + require.Error(t, err) + }) + t.Run("test none file created", func(t *testing.T) { + tm := time.Now() + kt, clean, err := NewMockKeytab( + WithPrincipal("HTTP/sso.example.com"), + WithRealm("TEST.LOCAL"), + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: tm, + }), + ) + require.NoError(t, err) + t.Cleanup(clean) + _, kv, err := kt.GetEncryptionKey(types.NewPrincipalName(1, "HTTP/sso.example.com"), "TEST.LOCAL", 3, 18) + require.NoError(t, err) + require.Equal(t, 3, kv) + }) + t.Run("test file open failed", func(t *testing.T) { + prevFileOperator := defaultFileOperator + defaultFileOperator = mockFileOperator{flag: 0x01} + t.Cleanup(func() { + defaultFileOperator = prevFileOperator + }) + _, _, err := NewMockKeytab( + WithPrincipal("HTTP/sso.example.com"), + WithRealm("TEST.LOCAL"), + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: time.Now(), + }), + WithFilename("./temp.keytab"), + ) + require.ErrorIs(t, err, os.ErrPermission) + require.NoFileExists(t, "./temp.keytab") + }) + t.Run("test file write failed", func(t *testing.T) { + prevFileOperator := defaultFileOperator + defaultFileOperator = mockFileOperator{flag: 0x02} + t.Cleanup(func() { + defaultFileOperator = prevFileOperator + }) + _, _, err := NewMockKeytab( + WithPrincipal("HTTP/sso.example.com"), + WithRealm("TEST.LOCAL"), + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: time.Now(), + }), + WithFilename("./temp.keytab"), + ) + require.ErrorIs(t, err, os.ErrClosed) + require.NoFileExists(t, "./temp.keytab") + }) + t.Run("test file created", func(t *testing.T) { + filename := path.Join(t.TempDir(), "temp.keytab") + tm := time.Now() + _, clean, err := NewMockKeytab( + WithPrincipal("HTTP/sso.example.com"), + WithRealm("TEST.LOCAL"), + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: tm, + }), + WithFilename(filename), + ) + require.NoError(t, err) + t.Cleanup(clean) + require.FileExists(t, filename) + kt, err := keytab.Load(filename) + require.NoError(t, err) + _, kv, err := kt.GetEncryptionKey(types.NewPrincipalName(1, "HTTP/sso.example.com"), "TEST.LOCAL", 3, 18) + require.NoError(t, err) + require.Equal(t, 3, kv) + }) +} + +func TestWithFilename(t *testing.T) { + opts := mockOptions{} + require.Empty(t, opts.Filename) + WithFilename("/tmp/test.keytab")(&opts) + require.Equal(t, "/tmp/test.keytab", opts.Filename) +} + +func TestWithPairs(t *testing.T) { + opts := mockOptions{} + tm := time.Now() + require.Len(t, opts.Pairs, 0) + WithPairs(EncryptTypePair{ + Version: 2, + EncryptType: 17, + CreateTime: tm.Add(-time.Minute), + }, EncryptTypePair{ + Version: 2, + EncryptType: 18, + CreateTime: tm.Add(-time.Minute), + })(&opts) + require.Len(t, opts.Pairs, 2) + WithPairs(EncryptTypePair{ + Version: 3, + EncryptType: 18, + CreateTime: tm, + })(&opts) + require.Len(t, opts.Pairs, 3) + require.Equal(t, opts.Pairs, []EncryptTypePair{ + {Version: 2, EncryptType: 17, CreateTime: tm.Add(-time.Minute)}, + {Version: 2, EncryptType: 18, CreateTime: tm.Add(-time.Minute)}, + {Version: 3, EncryptType: 18, CreateTime: tm}, + }) +} + +func TestWithPassword(t *testing.T) { + opts := mockOptions{} + require.Empty(t, opts.Password) + WithPassword("abcd1234")(&opts) + require.Equal(t, "abcd1234", opts.Password) +} + +func TestWithPrincipal(t *testing.T) { + opts := mockOptions{} + require.Empty(t, opts.PrincipalName) + WithPrincipal("HTTP/sso.example.local")(&opts) + require.Equal(t, "HTTP/sso.example.local", opts.PrincipalName) +} + +func TestWithRealm(t *testing.T) { + opts := mockOptions{} + require.Empty(t, opts.Realm) + WithRealm("EXAMPLE.LOCAL")(&opts) + require.Equal(t, "EXAMPLE.LOCAL", opts.Realm) +} + +func Test_mockOptions_apply(t *testing.T) { + opts := mockOptions{} + require.Empty(t, opts.Filename) + require.Empty(t, opts.Realm) + opts.apply(WithFilename("/tmp/test.keytab"), WithRealm("TEST.LOCAL")) + require.Equal(t, "/tmp/test.keytab", opts.Filename) + require.Equal(t, "TEST.LOCAL", opts.Realm) +}