From 4d63917edff758a7eea0e60998427910f180fc82 Mon Sep 17 00:00:00 2001 From: Daniel Aschwanden Date: Sat, 16 Sep 2023 22:32:12 +0200 Subject: [PATCH] fix routing and auth handling --- go.mod | 11 +- go.sum | 6 + pkg/auth/config.go | 154 ++++++++++++++++++ pkg/auth/config_test.go | 104 ++++++++++++ pkg/auth/oidc.go | 339 ++++++++++++++++++++++++++++++++++++++++ pkg/config/auth.go | 2 +- pkg/router/router.go | 6 +- ui/src/App.tsx | 2 +- 8 files changed, 615 insertions(+), 9 deletions(-) create mode 100644 pkg/auth/config.go create mode 100644 pkg/auth/config_test.go create mode 100644 pkg/auth/oidc.go diff --git a/go.mod b/go.mod index fe2c17ce..81b74fd5 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,23 @@ go 1.19 require ( github.com/99designs/gqlgen v0.17.24 github.com/TJM/gin-gonic-oidcauth v0.3.0 + github.com/franela/goblin v0.0.0-20210113153425-413781f5e6c8 github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/static v0.0.1 github.com/gin-gonic/gin v1.8.2 github.com/google/uuid v1.3.0 + github.com/onsi/gomega v1.5.0 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.15.0 github.com/vektah/gqlparser/v2 v2.5.1 + golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 ) require ( github.com/agnivade/levenshtein v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect - github.com/coreos/go-oidc/v3 v3.5.0 // indirect + github.com/coreos/go-oidc/v3 v3.5.0 github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -40,7 +43,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/leodido/go-urn v1.2.1 // indirect - github.com/letsencrypt/boulder v0.0.0-20230130081212-d2d9078213db // indirect + github.com/letsencrypt/boulder v0.0.0-20230130081212-d2d9078213db github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect @@ -69,8 +72,8 @@ require ( go.opentelemetry.io/otel/trace v1.13.0 golang.org/x/crypto v0.6.0 // indirect golang.org/x/mod v0.6.0 // indirect - golang.org/x/net v0.7.0 // indirect - golang.org/x/oauth2 v0.3.0 // indirect + golang.org/x/net v0.7.0 + golang.org/x/oauth2 v0.3.0 golang.org/x/sys v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect golang.org/x/tools v0.2.0 // indirect diff --git a/go.sum b/go.sum index 87ba0e23..5a60a94d 100644 --- a/go.sum +++ b/go.sum @@ -245,6 +245,7 @@ github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+l github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -326,6 +327,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -478,6 +480,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 h1:fiNkyhJPUvxbRPbCqY/D9qdjmPzfHcpK3P4bM4gioSY= +golang.org/x/exp v0.0.0-20230118134722-a68e582fa157/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -817,6 +821,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= @@ -824,6 +829,7 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/auth/config.go b/pkg/auth/config.go new file mode 100644 index 00000000..9d859e27 --- /dev/null +++ b/pkg/auth/config.go @@ -0,0 +1,154 @@ +package auth + +import ( + "errors" + "log" + "os" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// Config represents available options for oidcauth. +type Config struct { + // ClientID is the OAUTH2 Client ID + // Default value is: (read from OS ENV: OAUTH2_CLIENT_ID) + ClientID string + + // ClientSecret is the OAUTH2 Client Secret + // Default value is: (read from OS ENV: OAUTH2_CLIENT_SECRET) + ClientSecret string + + // IssuerURL is the root URL to theIdentity Provider + // Default value is: (read from OS ENV: OIDC_ISSUER_URL) + IssuerURL string + + // RedirectURL is the path that the Identity Provider will redirect clients to + // Default value is: (read from OS ENV: OIDC_REDIRECT_URL) + RedirectURL string + + // Scopes is a list of OIDC Scopes to request. + // Default value is: []string{oidc.ScopeOpenID, "profile", "email"} + Scopes []string + + // LoginClaim is the OIDC claim to map to the user's login (username) + // Default value is: "email" + LoginClaim string + + // SessionClaims is the list of OIDC claims to add to the user's session (in addition to LoginClaim) + // Example []string{"email", "givenName", "name"} + // NOTE: This can be set to ["*"] to load *all* claims. (nonce will be excluded) + // Default value is: ["*"] + SessionClaims []string + + // SessionPrefix is an optional prefix string to prefix to the claims (i.e. google: or corp:) to prevent + // clashes in the session namespace + // Default value is: "" + SessionPrefix string + + // DefaultAuthenticatedURL is the URL to redirect a user to after successful authentication. By default, we will + // try to determine where they were when they requested to login and send them back there. + // Default value is: "/" + DefaultAuthenticatedURL string + + // LogoutURL is the URL to redirect a user to after logging out. + // NOTE: If you require / to be authenticated, setting this to / will start the login process immediately, which may not be desirable. + // Default value is: "/" + LogoutURL string +} + +// DefaultConfig will create a new config object with defaults +// NOTE: This matches the examples on https://github.com/coreos/go-oidc/tree/v3/example +func DefaultConfig() (c *Config) { + c = &Config{ + ClientID: os.Getenv("OIDC_CLIENT_ID"), + ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + IssuerURL: os.Getenv("OIDC_ISSUER_URL"), + RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + LoginClaim: "email", + SessionClaims: []string{"*"}, + DefaultAuthenticatedURL: "/", + LogoutURL: "/", + } + return +} + +// ExampleConfigDex will return the config for a default DEX IdP example-app +// DEX: https://github.com/dexidp/dex +func ExampleConfigDex() (c *Config) { + c = DefaultConfig() + c.ClientID = "example-app" + c.ClientSecret = "ZXhhbXBsZS1hcHAtc2VjcmV0" + c.IssuerURL = "http://127.0.0.1:5556/dex" + c.RedirectURL = "http://127.0.0.1:5555/callback" + return +} + +// ExampleConfigGoogle will return the config for the Google Accounts IdP like the go-oidc examples +// go-oidc google example: https://github.com/coreos/go-oidc/tree/v3/example +func ExampleConfigGoogle() (c *Config) { + c = DefaultConfig() + c.ClientID = os.Getenv("GOOGLE_OAUTH2_CLIENT_ID") + c.ClientSecret = os.Getenv("GOOGLE_OAUTH2_CLIENT_SECRET") + c.IssuerURL = "https://accounts.google.com" + c.RedirectURL = "http://127.0.0.1:5556/auth/google/callback" + return +} + +// Validate will validate the Config +func (c Config) Validate() (err error) { + + if c.ClientID == "" { + err = errors.New("ClientID is required") + return + } + + if c.ClientSecret == "" { + err = errors.New("ClientSecret is required") + return + } + + if c.IssuerURL == "" { // TODO: Validate that its a properly formed URL + err = errors.New("IssuerURL is required") + return + } + + if c.RedirectURL == "" { // TODO: Validate that its a properly formed URL + err = errors.New("RedirectURL is required") + return + } + + return +} + +// GetOidcAuth returns the configured OIDC authentication controller +func GetOidcAuth(c *Config) (o *OidcAuth, err error) { + return c.GetOidcAuth() +} + +// GetOidcAuth returns the configured OIDC authentication controller +func (c *Config) GetOidcAuth() (o *OidcAuth, err error) { + err = c.Validate() + if err != nil { + log.Fatal(err) + } + return newOidcAuth(c) +} + +// The methods below can be used to return the middleware, but currently do +// not handle the routes. They are of limited use, for now. +// +// // Default returns the location middleware with default configuration. +// func Default() gin.HandlerFunc { +// config := DefaultConfig() +// return New(config) +// } + +// // New returns the location middleware with user-defined custom configuration. +// func New(c *Config) gin.HandlerFunc { +// auth, err := c.GetOidcAuth() +// if err != nil { +// log.Fatal("[oidcauth] Error getting auth handler") +// } +// return auth.AuthRequired() +// } diff --git a/pkg/auth/config_test.go b/pkg/auth/config_test.go new file mode 100644 index 00000000..af449daf --- /dev/null +++ b/pkg/auth/config_test.go @@ -0,0 +1,104 @@ +package auth + +import ( + "os" + "testing" + + goblin "github.com/franela/goblin" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + g := goblin.Goblin(t) + + //special hook for gomega + RegisterFailHandler(func(m string, _ ...int) { g.Fail(m) }) + + g.Describe("TestConfig", func() { + + g.Describe("DefaultConfig", func() { + os.Setenv("OIDC_CLIENT_ID", "client-id") + os.Setenv("OIDC_CLIENT_SECRET", "client-secret") + os.Setenv("OIDC_ISSUER_URL", "issuer-url") + os.Setenv("OIDC_REDIRECT_URL", "redirect-url") + c := DefaultConfig() + + g.It("should retrieve values from env", func() { + Expect(c.ClientID).To(BeEquivalentTo("client-id")) + Expect(c.ClientSecret).To(BeEquivalentTo("client-secret")) + Expect(c.IssuerURL).To(BeEquivalentTo("issuer-url")) + Expect(c.RedirectURL).To(BeEquivalentTo("redirect-url")) + }) + }) + + g.Describe("ExampleConfigDex", func() { + c := ExampleConfigDex() + + g.It("should match dex example-app config", func() { + Expect(c.ClientID).To(BeEquivalentTo("example-app")) + Expect(c.ClientSecret).To(BeEquivalentTo("ZXhhbXBsZS1hcHAtc2VjcmV0")) + Expect(c.IssuerURL).To(BeEquivalentTo("http://127.0.0.1:5556/dex")) + Expect(c.RedirectURL).To(BeEquivalentTo("http://127.0.0.1:5555/callback")) + }) + }) + + g.Describe("ExampleConfigGoogle", func() { + os.Setenv("GOOGLE_OAUTH2_CLIENT_ID", "client-id") + os.Setenv("GOOGLE_OAUTH2_CLIENT_SECRET", "client-secret") + c := ExampleConfigGoogle() + + g.It("should match example google config", func() { + Expect(c.ClientID).To(BeEquivalentTo("client-id")) + Expect(c.ClientSecret).To(BeEquivalentTo("client-secret")) + Expect(c.IssuerURL).To(BeEquivalentTo("https://accounts.google.com")) + Expect(c.RedirectURL).To(BeEquivalentTo("http://127.0.0.1:5556/auth/google/callback")) + }) + }) + + g.Describe("Validate", func() { + c := ExampleConfigDex() + c.ClientID = "" + g.It("should error on empty ClientID", func() { + Expect(c.Validate()).ToNot(BeNil()) + }) + + c = ExampleConfigDex() + c.ClientSecret = "" + g.It("should error on empty ClientSecret", func() { + Expect(c.Validate()).ToNot(BeNil()) + }) + + c = ExampleConfigDex() + c.IssuerURL = "" + g.It("should error on empty IssuerURL", func() { + Expect(c.Validate()).ToNot(BeNil()) + }) + + c = ExampleConfigDex() + c.RedirectURL = "" + g.It("should error on empty RedirectURL", func() { + Expect(c.Validate()).ToNot(BeNil()) + }) + }) + + // g.Describe("GetOidcAuth", func() { + // auth, err := GetOidcAuth(ExampleConfigDex()) + + // g.It("should work", func() { + // Expect(auth).NotTo(BeNil()) + // Expect(err).To(BeNil()) + // }) + // }) + + // g.Describe("c.GetOidcAuth", func() { + // c := ExampleConfigDex() + // auth, err := c.GetOidcAuth() + + // g.It("should work", func() { + // Expect(auth).NotTo(BeNil()) + // Expect(err).To(BeNil()) + // }) + // }) + + }) +} diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 00000000..79d5efaa --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,339 @@ +package auth + +import ( + "errors" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/nonce" + + "golang.org/x/exp/slog" + "golang.org/x/net/context" + "golang.org/x/oauth2" +) + +const ( + // oidcStateSessionKey is used to validate callback from client, see: https://auth0.com/docs/protocols/state-parameters + oidcStateSessionKey string = "auth:state" + + // previousURLSessionKey will temporarily hold the URL path that the user was at before authentication started + previousURLSessionKey string = "auth:PreviousURL" + + // accessTokenSessionKey is the session key to hold the oauth access token + accessTokenSessionKey string = "auth:AccessToken" + + // loginSessionKey is the session key to hold the "login" (username) + loginSessionKey string = "auth:login" + + // expirationSessionKey is the when the session is expired (in unixtime as an uint) + expirationSessionKey string = "auth:sessionExpiration" + + // AuthUserKey stores the authenticated user's login (username or email) in this context key + AuthUserKey string = "user" +) + +// OidcAuth handles OIDC Authentication +type OidcAuth struct { + ctx context.Context + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth2Config *oauth2.Config + nonceService *nonce.NonceService + config *Config + Debug bool // DUMP oidc paramters as JSON instead of redirecting +} + +// newOidcAuth returns the oidcAuth struct, expects config to have been validated +func newOidcAuth(c *Config) (o *OidcAuth, err error) { + o = new(OidcAuth) + + o.ctx = context.Background() + + provider, err := oidc.NewProvider(o.ctx, c.IssuerURL) + if err != nil { + slog.Error("cannot create oidc provider", err) + } + o.provider = provider + + oidcConfig := &oidc.Config{ + ClientID: c.ClientID, + } + // Use the nonce source to create a custom ID Token verifier. + o.verifier = o.provider.Verifier(oidcConfig) + + o.oauth2Config = &oauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: c.RedirectURL, + Scopes: c.Scopes, + } + + ns, err := nonce.NewNonceService(metrics.NoopRegisterer, 0, "oidc") + if err != nil { + slog.Error("cannot create nonce service", err) + } + o.nonceService = ns + + o.config = c // Save Config + return +} + +// AuthRequired middleware requires OIDC authentication +// BE CAREFUL Adding this to / (or the top level router) +func (o *OidcAuth) AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + e := session.Get(expirationSessionKey) + l := session.Get(loginSessionKey) + if l == nil || e == nil { + o.doAuthentication(c) + c.Abort() + return + } + + login := l.(string) + exp := time.Unix(int64(e.(float64)), 0) // e (float64) -> int64 -> unixtime -> time.Time + now := time.Now() + + if now.After(exp) { + slog.Info("session expired", slog.String("login", login), slog.Time("exp", exp)) + o.doAuthentication(c) + c.Abort() + return + } + // The user credentials was found, set user's loginClaim to key AuthUserKey in this context, the user's id can be read later using + // c.MustGet(oidcauth.AuthUserKey). + c.Set(AuthUserKey, login) + c.Next() + } +} + +// AuthRequired middleware requires OIDC authentication +// BE CAREFUL Adding this to / (or the top level router) +func (o *OidcAuth) AuthRequiredWithoutRedirect() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + e := session.Get(expirationSessionKey) + l := session.Get(loginSessionKey) + if l == nil || e == nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + login := l.(string) + exp := time.Unix(int64(e.(float64)), 0) // e (float64) -> int64 -> unixtime -> time.Time + now := time.Now() + + if now.After(exp) { + slog.Info("session expired", slog.String("login", login), slog.Time("exp", exp)) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + // The user credentials was found, set user's loginClaim to key AuthUserKey in this context, the user's id can be read later using + // c.MustGet(oidcauth.AuthUserKey). + c.Set(AuthUserKey, login) + c.Next() + } +} + +// Login will setup the appropriate state and redirect the user to the authentication provider +func (o *OidcAuth) Login(c *gin.Context) { + state := o.generateState(c) + nonce := o.generateNonce(c) + session := sessions.Default(c) + session.Set(oidcStateSessionKey, state) + err := session.Save() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("Error saving session: "+err.Error())) + return + } + c.Redirect(http.StatusFound, o.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce))) +} + +// Logout will clear the session +// NOTE: It will not invalidate the OIDC session (Not SSO) +func (o *OidcAuth) Logout(c *gin.Context) { + session := sessions.Default(c) + // These Sets will mark the session as "written" and clear the values (jic) + // session.Set(accessTokenSessionKey, nil) + session.Set(loginSessionKey, nil) + session.Clear() + session.Options(sessions.Options{Path: "/", MaxAge: -1}) // this sets the cookie as expired + session.Save() + c.Redirect(http.StatusTemporaryRedirect, o.config.LogoutURL) +} + +// AuthCallback will handle the authentication callback (redirect) from the Identity Provider +// +// This is the part that actually "does" the authentication. +func (o *OidcAuth) AuthCallback(c *gin.Context) { + sessionState, err := o.getState(c) + if err != nil { + slog.Error("cannot retrieve state", err) + c.AbortWithError(http.StatusBadRequest, errors.New("[auth] unable to retrieve state: "+err.Error())) + return + } + if c.Query("state") != sessionState { + slog.Error("auth state did not macht", err, slog.String("queryState", c.Query("state")), slog.String("sessionState", sessionState)) + c.AbortWithError(http.StatusBadRequest, errors.New("[auth] state did not match")) + return + } + + oauth2Token, err := o.oauth2Config.Exchange(o.ctx, c.Query("code")) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Failed to exchange token: "+err.Error())) + return + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] No id_token field in oauth2 token")) + return + } + + // Verify the ID Token signature and nonce. + idToken, err := o.verifier.Verify(o.ctx, rawIDToken) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Failed to verify ID Token: "+err.Error())) + return + } + if !o.nonceService.Valid(idToken.Nonce) { + c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Invalid ID Token nonce")) + return + } + + // IDTokenClaims := new(json.RawMessage) // ID Token payload is just JSON. + claims := make(map[string]interface{}) + if err := idToken.Claims(&claims); err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Failed retrieve claims: "+err.Error())) + return + } + + // Save to session + session := sessions.Default(c) + session.AddFlash("Authentication Successful!") + + // Process Results - just dump everything into the session for now (probably not a good idea) + // session.Set(accessTokenSessionKey, oauth2Token.AccessToken) // sessions doesn't like very long AccessToken + // session.Set("TokenType", oauth2Token.TokenType) // Not Needed? + // session.Set("Expiry", oauth2Token.Expiry) // sessions doesn't like time.Time + delete(claims, "nonce") // No longer useful + + // Add claims to session + if len(o.config.SessionClaims) > 0 { + if o.config.SessionClaims[0] == "*" { // Set All Claims in Session + for claim, val := range claims { + sessionKey := o.config.SessionPrefix + claim + session.Set(sessionKey, val) + } + } else { + for _, sessionClaim := range o.config.SessionClaims { + if val, ok := claims[sessionClaim]; ok { + sessionKey := o.config.SessionPrefix + sessionClaim + session.Set(sessionKey, val) + } + } + } + } + + // Set login in session + if login, ok := claims[o.config.LoginClaim]; ok { + session.Set(loginSessionKey, login) + } + + // Set expiration in session + if exp, ok := claims["exp"]; ok { + session.Set(expirationSessionKey, exp) + } + + redirectURL := o.config.DefaultAuthenticatedURL + u := session.Get(previousURLSessionKey) + if u != nil { + redirectURL = u.(string) + session.Delete(previousURLSessionKey) + } + + err = session.Save() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("Error saving session: "+err.Error())) + return + } + + if o.Debug { + c.JSON(http.StatusOK, gin.H{ + "redirectURL": redirectURL, + "rawIDToken": rawIDToken, + "idToken": idToken, + "oauth2Token": oauth2Token, + "claims": claims, + }) + return + } + c.Redirect(http.StatusFound, redirectURL) +} + +// getState will return the state string (and/or err) from the session +// NOTE: state is a string that is passed to the authentication provider, and returned to validate we sent the reqest. +func (o *OidcAuth) getState(c *gin.Context) (state string, err error) { + session := sessions.Default(c) + s := session.Get(oidcStateSessionKey) + session.Delete(oidcStateSessionKey) + session.Save() + if s == nil { + err = errors.New("state was not found in session") + slog.Error("no state found", err) + return + } + if !o.nonceService.Valid(s.(string)) { + err = errors.New("state was not a valid nonce") + slog.Error("invalid nonce", err) + return + } + state = s.(string) + return + +} + +// generateState will generate the random string to be used for "state" in the oidc requests +// +// Opaque value used to maintain state between the request and the callback. +// Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically +// binding the value of this parameter with a browser cookie. +func (o *OidcAuth) generateState(c *gin.Context) (state string) { + return o.generateNonce(c) // just use a nonce for now +} + +// generateNonce will generate a nonce (one time use, random string), aborts on error +func (o *OidcAuth) generateNonce(c *gin.Context) (nonce string) { + nonce, err := o.nonceService.Nonce() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("Error getting nonce: "+err.Error())) + } + return +} + +// doAuthentication is designed to be called from middleware when it determines +// +// that the user is not authenticated. It will attempt to return the user to +// the path they were requesting when authentication was required. +func (o *OidcAuth) doAuthentication(c *gin.Context) { + session := sessions.Default(c) + previousURL := c.Request.RequestURI // Current URL + if previousURL == "" { + previousURL = o.config.DefaultAuthenticatedURL + } + session.Set(previousURLSessionKey, c.Request.RequestURI) + err := session.Save() + if err != nil { + slog.Error("cannot save session", err) + c.AbortWithError(http.StatusInternalServerError, err) + } + + o.Login(c) + return +} diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 3076d9fa..4ee96364 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -3,7 +3,7 @@ package config import ( "fmt" - oidcauth "github.com/TJM/gin-gonic-oidcauth" + oidcauth "github.com/RedGecko/sitrep/pkg/auth" "github.com/spf13/viper" ) diff --git a/pkg/router/router.go b/pkg/router/router.go index 9f7caf8c..cb7a1021 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -9,9 +9,9 @@ import ( "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/playground" "github.com/RedGecko/sitrep/graph" + oidcauth "github.com/RedGecko/sitrep/pkg/auth" "github.com/RedGecko/sitrep/pkg/config" "github.com/RedGecko/sitrep/ui" - oidcauth "github.com/TJM/gin-gonic-oidcauth" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/static" @@ -86,10 +86,10 @@ func CreateRouter() (*gin.Engine, error) { oidc.GET("/sign_in", auth.Login) oidc.GET("/callback", auth.AuthCallback) oidc.GET("/sign_out", auth.Logout) - oidc.GET("/userinfo", auth.AuthRequired(), userInfoHandler()) + oidc.GET("/userinfo", auth.AuthRequiredWithoutRedirect(), userInfoHandler()) oidc.GET("/index.html", auth.AuthRequired()) - api := r.Group("/api/v1", auth.AuthRequired()) + api := r.Group("/api/v1", auth.AuthRequiredWithoutRedirect()) api.POST("/graphql", graphqlHandler()) admin := r.Group("/admin", auth.AuthRequired()) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 84635761..3a8571e3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -187,7 +187,7 @@ function App() { /> - } /> + } />