From 5763480920894e5f8ca2f9d4db041ff1f952830b Mon Sep 17 00:00:00 2001 From: Cosmin Tupangiu Date: Tue, 10 Dec 2024 15:52:06 +0100 Subject: [PATCH] auth: Add a simple RHSSO authentication This commit adds a simple rhsso authentication. It validates and parses the token and return a _User_ simple dto. Also, it adds a _NoneAuthenticator_ in case we want no authentication. Signed-off-by: Cosmin Tupangiu --- go.mod | 3 + go.sum | 6 + internal/api_server/server.go | 7 + internal/auth/auth.go | 25 +++ internal/auth/auth_suite_test.go | 13 ++ internal/auth/none_authenticator.go | 26 +++ internal/auth/rhsso_authenticator.go | 82 +++++++++ internal/auth/rhsso_authenticator_test.go | 212 ++++++++++++++++++++++ internal/auth/user.go | 27 +++ internal/config/config.go | 6 + 10 files changed, 407 insertions(+) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_suite_test.go create mode 100644 internal/auth/none_authenticator.go create mode 100644 internal/auth/rhsso_authenticator.go create mode 100644 internal/auth/rhsso_authenticator_test.go create mode 100644 internal/auth/user.go diff --git a/go.mod b/go.mod index 0c580ee..a861529 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.22.2 require ( github.com/IBM/sarama v1.43.3 + github.com/MicahParks/keyfunc/v3 v3.3.5 github.com/cloudevents/sdk-go/v2 v2.15.2 github.com/coreos/butane v0.22.0 github.com/getkin/kin-openapi v0.126.0 github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/render v1.0.3 + github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 github.com/konveyor/forklift-controller v0.0.0-20221102112227-e73b65a01cda github.com/kubev2v/migration-event-streamer v0.0.0-20241125102656-9cdf9e64a16b @@ -38,6 +40,7 @@ require ( ) require ( + github.com/MicahParks/jwkset v0.5.19 // indirect github.com/ajg/form v1.5.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go v1.50.25 // indirect diff --git a/go.sum b/go.sum index 25d70b2..e82499e 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/IBM/sarama v1.43.3 h1:Yj6L2IaNvb2mRBop39N7mmJAHBVY3dTPncr3qGVkxPA= github.com/IBM/sarama v1.43.3/go.mod h1:FVIRaLrhK3Cla/9FfRF5X9Zua2KpS3SYIXxhac1H+FQ= +github.com/MicahParks/jwkset v0.5.19 h1:XZCsgJv05DBCvxEHYEHlSafqiuVn5ESG0VRB331Fxhw= +github.com/MicahParks/jwkset v0.5.19/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo= +github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= @@ -153,6 +157,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/internal/api_server/server.go b/internal/api_server/server.go index 46ea1ae..414d36e 100644 --- a/internal/api_server/server.go +++ b/internal/api_server/server.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5/middleware" api "github.com/kubev2v/migration-planner/api/v1alpha1" "github.com/kubev2v/migration-planner/internal/api/server" + "github.com/kubev2v/migration-planner/internal/auth" "github.com/kubev2v/migration-planner/internal/config" "github.com/kubev2v/migration-planner/internal/events" "github.com/kubev2v/migration-planner/internal/image" @@ -75,8 +76,14 @@ func (s *Server) Run(ctx context.Context) error { ErrorHandler: oapiErrorHandler, } + authenticator, err := auth.NewAuthenticator(s.cfg.Service.Auth) + if err != nil { + return fmt.Errorf("failed to create authenticator: %w", err) + } + router := chi.NewRouter() router.Use( + authenticator.Authenticator, middleware.RequestID, zapchi.Logger(zap.S(), "router_api"), middleware.Recoverer, diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..565744f --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,25 @@ +package auth + +import ( + "net/http" + + "github.com/kubev2v/migration-planner/internal/config" +) + +type Authenticator interface { + Authenticator(next http.Handler) http.Handler +} + +const ( + RHSSOAuthentication string = "rhsso" + NoneAuthentication string = "none" +) + +func NewAuthenticator(authConfig config.Auth) (Authenticator, error) { + switch authConfig.AuthenticationType { + case RHSSOAuthentication: + return NewRHSSOAuthenticator(authConfig.JwtCertUrl) + default: + return NewNoneAuthenticator() + } +} diff --git a/internal/auth/auth_suite_test.go b/internal/auth/auth_suite_test.go new file mode 100644 index 0000000..cc266fc --- /dev/null +++ b/internal/auth/auth_suite_test.go @@ -0,0 +1,13 @@ +package auth_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAuth(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Auth Suite") +} diff --git a/internal/auth/none_authenticator.go b/internal/auth/none_authenticator.go new file mode 100644 index 0000000..f7433db --- /dev/null +++ b/internal/auth/none_authenticator.go @@ -0,0 +1,26 @@ +package auth + +import ( + "net/http" + + "go.uber.org/zap" +) + +type NoneAuthenticator struct{} + +func NewNoneAuthenticator() (*NoneAuthenticator, error) { + return &NoneAuthenticator{}, nil +} + +func (n *NoneAuthenticator) Authenticator(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + zap.S().Named("auth").Info("authentication disabled") + + user := User{ + Username: "admin", + Organization: "internal", + } + ctx := newContext(r.Context(), user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/auth/rhsso_authenticator.go b/internal/auth/rhsso_authenticator.go new file mode 100644 index 0000000..9416d08 --- /dev/null +++ b/internal/auth/rhsso_authenticator.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + keyfunc "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" +) + +type RHSSOAuthenticator struct { + keyFn func(t *jwt.Token) (any, error) +} + +func NewRHSSOAuthenticatorWithKeyFn(keyFn func(t *jwt.Token) (any, error)) (*RHSSOAuthenticator, error) { + return &RHSSOAuthenticator{keyFn: keyFn}, nil +} + +func NewRHSSOAuthenticator(jwkCertUrl string) (*RHSSOAuthenticator, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + k, err := keyfunc.NewDefaultCtx(ctx, []string{jwkCertUrl}) + if err != nil { + return nil, fmt.Errorf("failed to get sso public keys: %w", err) + } + + return &RHSSOAuthenticator{keyFn: k.Keyfunc}, nil +} + +func (rh *RHSSOAuthenticator) Authenticate(token string) (User, error) { + parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Name}), jwt.WithIssuedAt(), jwt.WithExpirationRequired()) + t, err := parser.Parse(token, rh.keyFn) + if err != nil { + zap.S().Errorw("failed to parse or the token is invalid", "token", token) + return User{}, fmt.Errorf("failed to authenticate token: %w", err) + } + + if !t.Valid { + zap.S().Errorw("failed to parse or the token is invalid", "token", token) + return User{}, fmt.Errorf("failed to parse or validate token") + } + + return rh.parseToken(t) +} + +func (rh *RHSSOAuthenticator) parseToken(userToken *jwt.Token) (User, error) { + claims, ok := userToken.Claims.(jwt.MapClaims) + if !ok { + return User{}, errors.New("failed to parse jwt token claims") + } + + return User{ + Username: claims["username"].(string), + Organization: claims["org_id"].(string), + ClientID: claims["client_id"].(string), + }, nil +} + +func (rh *RHSSOAuthenticator) Authenticator(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.Header.Get("Authorization") + if accessToken == "" || len(accessToken) < len("Bearer ") { + http.Error(w, "No token provided", http.StatusUnauthorized) + return + } + + accessToken = accessToken[len("Bearer "):] + user, err := rh.Authenticate(accessToken) + if err != nil { + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + + ctx := newContext(r.Context(), user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/auth/rhsso_authenticator_test.go b/internal/auth/rhsso_authenticator_test.go new file mode 100644 index 0000000..d1045ef --- /dev/null +++ b/internal/auth/rhsso_authenticator_test.go @@ -0,0 +1,212 @@ +package auth_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/kubev2v/migration-planner/internal/auth" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("sso authentication", func() { + Context("rh authentication", func() { + It("successfully validate the token", func() { + sToken, keyFn := generateValidToken() + authenticator, err := auth.NewRHSSOAuthenticatorWithKeyFn(keyFn) + Expect(err).To(BeNil()) + + user, err := authenticator.Authenticate(sToken) + Expect(err).To(BeNil()) + Expect(user.Username).To(Equal("batman")) + Expect(user.ClientID).To(Equal("batman_id")) + Expect(user.Organization).To(Equal("GothamCity")) + }) + + It("fails to authenticate -- wrong signing method", func() { + sToken, keyFn := generateInvalidTokenWrongSigningMethod() + authenticator, err := auth.NewRHSSOAuthenticatorWithKeyFn(keyFn) + Expect(err).To(BeNil()) + + _, err = authenticator.Authenticate(sToken) + Expect(err).ToNot(BeNil()) + }) + + It("fails to authenticate -- issueAt claims is missing", func() { + sToken, keyFn := generateInvalidValidToken("exp_at") + authenticator, err := auth.NewRHSSOAuthenticatorWithKeyFn(keyFn) + Expect(err).To(BeNil()) + + _, err = authenticator.Authenticate(sToken) + Expect(err).ToNot(BeNil()) + }) + }) + Context("rh auth middleware", func() { + It("successfully authenticate", func() { + sToken, keyFn := generateValidToken() + authenticator, err := auth.NewRHSSOAuthenticatorWithKeyFn(keyFn) + Expect(err).To(BeNil()) + + h := &handler{} + ts := httptest.NewServer(authenticator.Authenticator(h)) + defer ts.Close() + + req, err := http.NewRequest(http.MethodGet, ts.URL, nil) + Expect(err).To(BeNil()) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", sToken)) + + resp, rerr := http.DefaultClient.Do(req) + Expect(rerr).To(BeNil()) + Expect(resp.StatusCode).To(Equal(200)) + }) + + It("failed to authenticate", func() { + sToken, keyFn := generateInvalidTokenWrongSigningMethod() + authenticator, err := auth.NewRHSSOAuthenticatorWithKeyFn(keyFn) + Expect(err).To(BeNil()) + + h := &handler{} + ts := httptest.NewServer(authenticator.Authenticator(h)) + defer ts.Close() + + req, err := http.NewRequest(http.MethodGet, ts.URL, nil) + Expect(err).To(BeNil()) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", sToken)) + + resp, rerr := http.DefaultClient.Do(req) + Expect(rerr).To(BeNil()) + Expect(resp.StatusCode).To(Equal(401)) + }) + }) +}) + +type handler struct{} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) +} + +func generateValidToken() (string, func(t *jwt.Token) (any, error)) { + type TokenClaims struct { + Username string `json:"username"` + ClientID string `json:"client_id"` + OrgID string `json:"org_id"` + jwt.RegisteredClaims + } + + // Create claims with multiple fields populated + claims := TokenClaims{ + "batman", + "batman_id", + "GothamCity", + jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "test", + Subject: "somebody", + ID: "1", + Audience: []string{"somebody_else"}, + }, + } + + // generate a pair of keys RSA + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).To(BeNil()) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + ss, err := token.SignedString(privateKey) + Expect(err).To(BeNil()) + + return ss, func(t *jwt.Token) (any, error) { + return privateKey.Public(), nil + } +} + +func generateInvalidValidToken(missingClaim string) (string, func(t *jwt.Token) (any, error)) { + type TokenClaims struct { + Username string `json:"username"` + ClientID string `json:"client_id"` + OrgID string `json:"org_id"` + jwt.RegisteredClaims + } + + registedClaims := jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "test", + Subject: "somebody", + ID: "1", + Audience: []string{"somebody_else"}, + } + + switch missingClaim { + case "exp_at": + registedClaims.ExpiresAt = nil + } + + // Create claims with multiple fields populated + claims := TokenClaims{ + "batman", + "batman_id", + "GothamCity", + registedClaims, + } + + // generate a pair of keys RSA + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).To(BeNil()) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + ss, err := token.SignedString(privateKey) + Expect(err).To(BeNil()) + + return ss, func(t *jwt.Token) (any, error) { + return privateKey.Public(), nil + } +} + +func generateInvalidTokenWrongSigningMethod() (string, func(t *jwt.Token) (any, error)) { + type TokenClaims struct { + Username string `json:"username"` + ClientID string `json:"client_id"` + OrgID string `json:"org_id"` + jwt.RegisteredClaims + } + + // Create claims with multiple fields populated + claims := TokenClaims{ + "batman", + "batman_id", + "GothamCity", + jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "test", + Subject: "somebody", + ID: "1", + Audience: []string{"somebody_else"}, + }, + } + + // generate a pair of keys ecdsa + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).To(BeNil()) + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + ss, err := token.SignedString(privateKey) + Expect(err).To(BeNil()) + + return ss, func(t *jwt.Token) (any, error) { + return privateKey.Public(), nil + } +} diff --git a/internal/auth/user.go b/internal/auth/user.go new file mode 100644 index 0000000..fe23603 --- /dev/null +++ b/internal/auth/user.go @@ -0,0 +1,27 @@ +package auth + +import "context" + +type usernameKeyType struct{} + +var ( + usernameKey usernameKeyType +) + +func UserFromContext(ctx context.Context) (User, bool) { + val := ctx.Value(usernameKey) + if val == nil { + return User{}, false + } + return val.(User), true +} + +func newContext(ctx context.Context, u User) context.Context { + return context.WithValue(ctx, usernameKey, u) +} + +type User struct { + Username string + Organization string + ClientID string +} diff --git a/internal/config/config.go b/internal/config/config.go index c24741c..580d651 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,7 @@ type svcConfig struct { BaseAgentEndpointUrl string `json:"baseAgentEndpointUrl,omitempty"` LogLevel string `json:"logLevel,omitempty"` Kafka kafkaConfig `json:"kafka,omitempty"` + Auth Auth `json:"auth"` } type kafkaConfig struct { @@ -47,6 +48,11 @@ type kafkaConfig struct { SaramaConfig *sarama.Config } +type Auth struct { + AuthenticationType string `json:"type"` + JwtCertUrl string `json:"jwt_cert_url"` +} + func ConfigDir() string { return filepath.Join(util.MustString(os.UserHomeDir), "."+appName) }