diff --git a/go.mod b/go.mod index c8d33c258..3743c4c55 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( dubbo.apache.org/dubbo-go/v3 v3.0.0-20260210015753-35ea886421f9 github.com/apache/dubbo-go-hessian2 v1.12.5 github.com/armon/go-radix v1.0.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/dubbogo/go-zookeeper v1.0.4-0.20211212162352-f9d2183d89d5 github.com/duke-git/lancet/v2 v2.3.6 github.com/envoyproxy/go-control-plane/envoy v1.32.4 @@ -31,6 +32,7 @@ require ( github.com/gin-contrib/zap v1.1.6 github.com/gin-gonic/gin v1.10.1 github.com/go-co-op/gocron v1.9.0 + github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-logr/logr v1.4.2 github.com/go-logr/zapr v1.3.0 github.com/golang/protobuf v1.5.4 @@ -45,6 +47,7 @@ require ( github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.28.0 golang.org/x/text v0.31.0 google.golang.org/grpc v1.73.0 google.golang.org/protobuf v1.36.6 @@ -129,7 +132,6 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -247,7 +249,6 @@ require ( golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/go.sum b/go.sum index 414edca8b..1677cd32d 100644 --- a/go.sum +++ b/go.sum @@ -228,6 +228,8 @@ github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u9 github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -344,8 +346,8 @@ github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= diff --git a/pkg/config/console/auth/config.go b/pkg/config/console/auth/config.go index a9388eeda..4f27fafd8 100644 --- a/pkg/config/console/auth/config.go +++ b/pkg/config/console/auth/config.go @@ -19,26 +19,157 @@ package auth import ( "errors" + "fmt" + "net" + "net/url" + "regexp" + "slices" + "strings" "github.com/apache/dubbo-admin/pkg/config" ) -const DefaultExpirationTime = 7200 +const ( + DefaultExpirationTime = 7200 + DefaultSessionSecret = "secret" + + MethodPassword = "password" + ProviderTypeGitHub = "github" + ProviderTypeOIDC = "oidc" +) + +var providerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +type ProviderConfig struct { + Type string `json:"type" yaml:"type"` + DisplayName string `json:"displayName" yaml:"displayName"` + Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"` + ClientID string `json:"clientId" yaml:"clientId"` + ClientSecret string `json:"clientSecret" yaml:"clientSecret"` + RedirectURL string `json:"redirectUrl" yaml:"redirectUrl"` + PostLoginRedirectURL string `json:"postLoginRedirectUrl" yaml:"postLoginRedirectUrl"` + Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` +} // Config AuthConfig configure the valid user and password type Config struct { config.BaseConfig - User string `json:"user"` - Password string `json:"password"` - ExpirationTime int `json:"expirationTime"` + Methods []string `json:"methods" yaml:"methods"` + User string `json:"user" yaml:"user"` + Password string `json:"password" yaml:"password"` + ExpirationTime int `json:"expirationTime" yaml:"expirationTime"` + SessionSecret string `json:"sessionSecret" yaml:"sessionSecret"` + SessionCookieSecure bool `json:"sessionCookieSecure" yaml:"sessionCookieSecure"` + Providers map[string]ProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` +} + +func (c *Config) Sanitize() { + c.Password = config.SanitizedValue + c.SessionSecret = config.SanitizedValue + for id, provider := range c.Providers { + provider.ClientSecret = config.SanitizedValue + c.Providers[id] = provider + } } func (c *Config) Validate() error { - if c.User == "" || c.Password == "" { + if c.Methods == nil { + c.Methods = []string{MethodPassword} + } + // Methods contains built-in login methods only; OAuth and OIDC are configured through Providers. + for _, method := range c.Methods { + if method != MethodPassword { + return fmt.Errorf("auth: unsupported method %q", method) + } + } + if slices.Contains(c.Methods, MethodPassword) && (c.User == "" || c.Password == "") { return errors.New("auth: user or password is needed, but found empty") } if c.ExpirationTime <= 0 || c.ExpirationTime >= 24*60*60 { return errors.New("auth: expirationTime should be greater than 0 and less than 86400") } + if c.SessionSecret == "" { + c.SessionSecret = DefaultSessionSecret + } + for id, provider := range c.Providers { + if err := validateProvider(id, &provider); err != nil { + return err + } + c.Providers[id] = provider + } + return nil +} + +func validateProvider(id string, provider *ProviderConfig) error { + if !providerIDPattern.MatchString(id) { + return fmt.Errorf("auth: invalid provider id %q", id) + } + if provider.Type != ProviderTypeGitHub && provider.Type != ProviderTypeOIDC { + return fmt.Errorf("auth provider %q: unsupported type %q", id, provider.Type) + } + if provider.DisplayName == "" { + provider.DisplayName = id + } + if provider.ClientID == "" || provider.ClientSecret == "" { + return fmt.Errorf("auth provider %q: clientId and clientSecret are required", id) + } + redirect, err := validateHTTPURL(provider.RedirectURL) + if err != nil { + return fmt.Errorf("auth provider %q: invalid redirectUrl: %w", id, err) + } + expectedPath := "/api/v1/auth/providers/" + id + "/callback" + // The provider must return to the callback route registered for this provider ID. + if redirect.Path != expectedPath { + return fmt.Errorf("auth provider %q: redirectUrl must use callback path %q", id, expectedPath) + } + if _, err := validateHTTPURL(provider.PostLoginRedirectURL); err != nil { + return fmt.Errorf("auth provider %q: invalid postLoginRedirectUrl: %w", id, err) + } + switch provider.Type { + case ProviderTypeGitHub: + if len(provider.Scopes) == 0 { + provider.Scopes = []string{"read:user", "user:email"} + } + case ProviderTypeOIDC: + if _, err := validateOIDCURL(provider.Issuer); err != nil { + return fmt.Errorf("auth provider %q: invalid issuer: %w", id, err) + } + if len(provider.Scopes) == 0 { + provider.Scopes = []string{"openid", "profile", "email"} + } + if !slices.Contains(provider.Scopes, "openid") { + return fmt.Errorf("auth provider %q: OIDC scopes must include openid", id) + } + } return nil } + +func validateOIDCURL(raw string) (*url.URL, error) { + parsed, err := validateHTTPURL(raw) + if err != nil { + return nil, err + } + if parsed.Scheme == "https" || parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) { + return parsed, nil + } + return nil, errors.New("must use HTTPS, except for an HTTP loopback development endpoint") +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func validateHTTPURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(raw) + if err != nil { + return nil, err + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return nil, errors.New("must be an absolute HTTP or HTTPS URL") + } + return parsed, nil +} diff --git a/pkg/config/console/auth/config_test.go b/pkg/config/console/auth/config_test.go new file mode 100644 index 000000000..66a0e437e --- /dev/null +++ b/pkg/config/console/auth/config_test.go @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "strings" + "testing" +) + +func validConfig() *Config { + return &Config{User: "admin", Password: "secret", ExpirationTime: 3600} +} + +func TestConfigValidateDefaultsPasswordOnly(t *testing.T) { + cfg := validConfig() + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if len(cfg.Methods) != 1 || cfg.Methods[0] != MethodPassword { + t.Fatalf("Methods = %v, want [%s]", cfg.Methods, MethodPassword) + } + if cfg.SessionSecret != DefaultSessionSecret { + t.Fatalf("SessionSecret = %q, want legacy default", cfg.SessionSecret) + } +} + +func TestConfigValidatePreservesExplicitlyEmptyMethods(t *testing.T) { + cfg := validConfig() + cfg.Methods = []string{} + cfg.User = "" + cfg.Password = "" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.Methods == nil || len(cfg.Methods) != 0 { + t.Fatalf("Methods = %#v, want an explicitly empty slice", cfg.Methods) + } +} + +func TestConfigValidateProviders(t *testing.T) { + tests := []struct { + name string + id string + provider ProviderConfig + wantErr string + }{ + {name: "unsafe id", id: "../github", provider: validGitHubProvider("../github"), wantErr: "provider id"}, + {name: "unknown type", id: "github", provider: ProviderConfig{Type: "oauth", ClientID: "id", ClientSecret: "secret", RedirectURL: "https://admin.example/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "https://admin.example/admin/"}, wantErr: "type"}, + {name: "bad redirect", id: "github", provider: ProviderConfig{Type: ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", RedirectURL: "://bad", PostLoginRedirectURL: "https://admin.example/admin/"}, wantErr: "redirectUrl"}, + {name: "wrong callback", id: "github", provider: ProviderConfig{Type: ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", RedirectURL: "https://admin.example/wrong", PostLoginRedirectURL: "https://admin.example/admin/"}, wantErr: "callback"}, + {name: "oidc missing issuer", id: "sso", provider: ProviderConfig{Type: ProviderTypeOIDC, ClientID: "id", ClientSecret: "secret", RedirectURL: "https://admin.example/api/v1/auth/providers/sso/callback", PostLoginRedirectURL: "https://admin.example/admin/"}, wantErr: "issuer"}, + {name: "insecure oidc issuer", id: "sso", provider: ProviderConfig{Type: ProviderTypeOIDC, Issuer: "http://sso.example", ClientID: "id", ClientSecret: "secret", RedirectURL: "https://admin.example/api/v1/auth/providers/sso/callback", PostLoginRedirectURL: "https://admin.example/admin/"}, wantErr: "HTTPS"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Providers = map[string]ProviderConfig{tt.id: tt.provider} + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestConfigValidateAllowsLoopbackHTTPForOIDCDevelopment(t *testing.T) { + cfg := validConfig() + cfg.Providers = map[string]ProviderConfig{ + "sso": { + Type: ProviderTypeOIDC, Issuer: "http://127.0.0.1:5556", ClientID: "id", ClientSecret: "secret", + RedirectURL: "http://localhost:8888/api/v1/auth/providers/sso/callback", PostLoginRedirectURL: "http://localhost:8881/admin/", + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestConfigValidateProviderScopeDefaults(t *testing.T) { + cfg := validConfig() + cfg.Providers = map[string]ProviderConfig{ + "github": validGitHubProvider("github"), + "sso": { + Type: ProviderTypeOIDC, Issuer: "https://sso.example", ClientID: "id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/sso/callback", PostLoginRedirectURL: "https://admin.example/admin/", + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if got := strings.Join(cfg.Providers["github"].Scopes, " "); got != "read:user user:email" { + t.Fatalf("GitHub scopes = %q", got) + } + if got := strings.Join(cfg.Providers["sso"].Scopes, " "); got != "openid profile email" { + t.Fatalf("OIDC scopes = %q", got) + } +} + +func TestConfigValidateOIDCRequiresOpenIDScope(t *testing.T) { + cfg := validConfig() + cfg.Providers = map[string]ProviderConfig{ + "sso": { + Type: ProviderTypeOIDC, Issuer: "https://sso.example", ClientID: "id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/sso/callback", PostLoginRedirectURL: "https://admin.example/admin/", Scopes: []string{"profile"}, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "openid") { + t.Fatalf("Validate() error = %v, want openid error", err) + } +} + +func validGitHubProvider(id string) ProviderConfig { + return ProviderConfig{ + Type: ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/" + id + "/callback", PostLoginRedirectURL: "https://admin.example/admin/", + } +} diff --git a/pkg/config/console/config.go b/pkg/config/console/config.go index d3d526856..0c04703da 100644 --- a/pkg/config/console/config.go +++ b/pkg/config/console/config.go @@ -49,6 +49,12 @@ type Config struct { Auth *auth.Config `json:"auth" yaml:"auth"` } +func (c *Config) Sanitize() { + if c.Auth != nil { + c.Auth.Sanitize() + } +} + func (c *Config) Validate() error { if !supportedGinRunningMode.Contain(c.GinMode) { return bizerror.New(bizerror.ConfigError, fmt.Sprintf("invalid gin mode: %s", c.GinMode)) @@ -72,6 +78,10 @@ func (c *Config) Validate() error { if err := c.Auth.Validate(); err != nil { return err } + // Release deployments with external providers must reject the legacy default session secret and other short cookie-signing keys. + if c.GinMode == ReleaseMode && len(c.Auth.Providers) > 0 && len([]byte(c.Auth.SessionSecret)) < 32 { + return bizerror.New(bizerror.ConfigError, "auth sessionSecret must contain at least 32 bytes when providers are enabled in release mode") + } return nil } @@ -122,9 +132,11 @@ func DefaultConsoleConfig() *Config { GinMode: ReleaseMode, Port: 8888, Auth: &auth.Config{ + Methods: []string{auth.MethodPassword}, User: "admin", Password: "admin", ExpirationTime: 3600, + SessionSecret: auth.DefaultSessionSecret, }, } } diff --git a/pkg/config/console/config_test.go b/pkg/config/console/config_test.go new file mode 100644 index 000000000..34469db2a --- /dev/null +++ b/pkg/config/console/config_test.go @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package console + +import ( + "strings" + "testing" + + "github.com/apache/dubbo-admin/pkg/config/console/auth" +) + +func TestReleaseProviderRequiresStrongSessionSecret(t *testing.T) { + cfg := DefaultConsoleConfig() + cfg.Auth.Providers = map[string]auth.ProviderConfig{ + "github": { + Type: auth.ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "https://admin.example/admin/", + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "sessionSecret") { + t.Fatalf("Validate() error = %v, want sessionSecret error", err) + } + + cfg.Auth.SessionSecret = "a-long-deployment-specific-session-secret" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() with strong secret error = %v", err) + } +} + +func TestReleaseProviderRejectsShortSessionSecret(t *testing.T) { + cfg := DefaultConsoleConfig() + cfg.Auth.Providers = map[string]auth.ProviderConfig{ + "github": { + Type: auth.ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "https://admin.example/admin/", + }, + } + cfg.Auth.SessionSecret = "x" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "32 bytes") { + t.Fatalf("Validate() error = %v, want minimum sessionSecret length error", err) + } +} + +func TestDebugProviderAllowsLegacySessionSecret(t *testing.T) { + cfg := DefaultConsoleConfig() + cfg.GinMode = DebugMode + cfg.Auth.Providers = map[string]auth.ProviderConfig{ + "github": { + Type: auth.ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", + RedirectURL: "http://localhost:8888/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "http://localhost:8881/admin/", + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} diff --git a/pkg/config/display_test.go b/pkg/config/display_test.go new file mode 100644 index 000000000..4694dbe8f --- /dev/null +++ b/pkg/config/display_test.go @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package config_test + +import ( + "testing" + + "github.com/apache/dubbo-admin/pkg/config" + "github.com/apache/dubbo-admin/pkg/config/app" + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" +) + +func TestConfigForDisplaySanitizesConsoleAuthenticationSecrets(t *testing.T) { + cfg := app.DefaultAdminConfig() + cfg.Console.Auth.Password = "password-secret" + cfg.Console.Auth.SessionSecret = "session-secret" + cfg.Console.Auth.Providers = map[string]configauth.ProviderConfig{ + "sso": {ClientID: "public-client-id", ClientSecret: "provider-secret"}, + } + + display, err := config.ConfigForDisplay(&cfg) + if err != nil { + t.Fatalf("ConfigForDisplay() error = %v", err) + } + displayCfg := display.(*app.AdminConfig) + if displayCfg.Console.Auth.Password != config.SanitizedValue { + t.Fatalf("display password = %q, want sanitized value", displayCfg.Console.Auth.Password) + } + if displayCfg.Console.Auth.SessionSecret != config.SanitizedValue { + t.Fatalf("display sessionSecret = %q, want sanitized value", displayCfg.Console.Auth.SessionSecret) + } + provider := displayCfg.Console.Auth.Providers["sso"] + if provider.ClientSecret != config.SanitizedValue { + t.Fatalf("display provider clientSecret = %q, want sanitized value", provider.ClientSecret) + } + if provider.ClientID != "public-client-id" { + t.Fatalf("display provider clientId = %q, want public value preserved", provider.ClientID) + } + if cfg.Console.Auth.Password != "password-secret" || cfg.Console.Auth.SessionSecret != "session-secret" || cfg.Console.Auth.Providers["sso"].ClientSecret != "provider-secret" { + t.Fatal("ConfigForDisplay() mutated the runtime configuration") + } +} diff --git a/pkg/console/auth/github.go b/pkg/console/auth/github.go new file mode 100644 index 000000000..ce828f671 --- /dev/null +++ b/pkg/console/auth/github.go @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strings" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" +) + +const githubAPIBaseURL = "https://api.github.com" + +type githubEndpoints struct { + OAuth oauth2.Endpoint + APIBaseURL string +} + +type githubProvider struct { + id string + displayName string + postLoginRedirectURL string + oauth oauth2.Config + apiBaseURL string + httpClient *http.Client + canReadEmails bool +} + +type githubUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Email string `json:"email"` +} + +type githubEmail struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` +} + +func NewGitHubProvider(id string, cfg configauth.ProviderConfig) Provider { + return newGitHubProvider(id, cfg, githubEndpoints{OAuth: github.Endpoint, APIBaseURL: githubAPIBaseURL}, http.DefaultClient) +} + +func newGitHubProvider(id string, cfg configauth.ProviderConfig, endpoints githubEndpoints, client *http.Client) Provider { + return &githubProvider{ + id: id, + displayName: cfg.DisplayName, + postLoginRedirectURL: cfg.PostLoginRedirectURL, + oauth: oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, RedirectURL: cfg.RedirectURL, + Scopes: append([]string(nil), cfg.Scopes...), Endpoint: endpoints.OAuth, + }, + apiBaseURL: strings.TrimRight(endpoints.APIBaseURL, "/"), + httpClient: client, + canReadEmails: slices.Contains(cfg.Scopes, "user:email"), + } +} + +func (p *githubProvider) ID() string { return p.id } +func (p *githubProvider) DisplayName() string { return p.displayName } +func (p *githubProvider) NeedsNonce() bool { return false } +func (p *githubProvider) PostLoginRedirectURL() string { return p.postLoginRedirectURL } +func (p *githubProvider) AuthorizationURL(transaction OAuthTransaction) string { + return p.oauth.AuthCodeURL(transaction.State, + oauth2.SetAuthURLParam("code_challenge", PKCEChallenge(transaction.CodeVerifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256")) +} + +func (p *githubProvider) Authenticate(ctx context.Context, code, codeVerifier, _ string) (Principal, error) { + ctx = context.WithValue(ctx, oauth2.HTTPClient, p.httpClient) + token, err := p.oauth.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + if err != nil { + return Principal{}, fmt.Errorf("exchange GitHub authorization code: %w", err) + } + client := p.oauth.Client(ctx, token) + var user githubUser + if err := getGitHubJSON(ctx, client, p.apiBaseURL+"/user", &user); err != nil { + return Principal{}, fmt.Errorf("decode GitHub user: %w", err) + } + if user.ID <= 0 { + return Principal{}, errors.New("GitHub user numeric id is missing") + } + email := user.Email + if email == "" && p.canReadEmails { + var emails []githubEmail + if err := getGitHubJSON(ctx, client, p.apiBaseURL+"/user/emails", &emails); err != nil { + return Principal{}, fmt.Errorf("decode GitHub emails: %w", err) + } + email = selectGitHubEmail(emails) + } + return Principal{ + Subject: fmt.Sprintf("%s:%d", p.id, user.ID), Username: user.Login, Email: email, + Groups: []string{}, Roles: []string{}, AuthType: "oauth", Provider: p.id, + }, nil +} + +func getGitHubJSON(ctx context.Context, client *http.Client, endpoint string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(target) +} + +func selectGitHubEmail(emails []githubEmail) string { + for _, email := range emails { + if email.Primary && email.Verified { + return email.Email + } + } + for _, email := range emails { + if email.Verified { + return email.Email + } + } + return "" +} diff --git a/pkg/console/auth/github_test.go b/pkg/console/auth/github_test.go new file mode 100644 index 000000000..9a7d5363f --- /dev/null +++ b/pkg/console/auth/github_test.go @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + "golang.org/x/oauth2" +) + +func TestGitHubProviderMapsPrimaryVerifiedEmailAndUsesPKCE(t *testing.T) { + var tokenVerifier string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + tokenVerifier = r.Form.Get("code_verifier") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"provider-token","token_type":"bearer"}`)) + case "/user": + if got := r.Header.Get("Authorization"); got != "Bearer provider-token" { + t.Errorf("Authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": 123456, "login": "zhangsan", "email": ""}) + case "/user/emails": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"email": "other@example.com", "verified": true}, + {"email": "primary@example.com", "primary": true, "verified": true}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + provider := newGitHubProvider("github", configauth.ProviderConfig{ + Type: configauth.ProviderTypeGitHub, DisplayName: "GitHub", ClientID: "id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "https://admin.example/admin/", Scopes: []string{"read:user", "user:email"}, + }, githubEndpoints{OAuth: oauth2.Endpoint{AuthURL: server.URL + "/authorize", TokenURL: server.URL + "/token"}, APIBaseURL: server.URL}, server.Client()) + + principal, err := provider.Authenticate(context.Background(), "valid-code", "pkce-verifier", "") + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if tokenVerifier != "pkce-verifier" { + t.Fatalf("code_verifier = %q", tokenVerifier) + } + if principal.Subject != "github:123456" || principal.Username != "zhangsan" || principal.Email != "primary@example.com" || principal.AuthType != "oauth" || principal.Provider != "github" { + t.Fatalf("principal = %+v", principal) + } +} + +func TestGitHubProviderRejectsInvalidUserResponses(t *testing.T) { + tests := []struct { + name string + userBody string + wantErr string + }{ + {name: "missing id", userBody: `{"login":"octocat"}`, wantErr: "numeric id"}, + {name: "invalid json", userBody: `{`, wantErr: "decode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"token","token_type":"bearer"}`)) + case "/user": + _, _ = w.Write([]byte(tt.userBody)) + } + })) + defer server.Close() + provider := newGitHubProvider("github", configauth.ProviderConfig{ClientID: "id", ClientSecret: "secret", RedirectURL: "https://admin.example/callback"}, githubEndpoints{OAuth: oauth2.Endpoint{TokenURL: server.URL + "/token"}, APIBaseURL: server.URL}, server.Client()) + _, err := provider.Authenticate(context.Background(), "code", "verifier", "") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), tt.wantErr) { + t.Fatalf("Authenticate() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestGitHubProviderSkipsEmailEndpointWithoutUserEmailScope(t *testing.T) { + emailRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"token","token_type":"bearer"}`)) + case "/user": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "login": "octocat", "email": ""}) + case "/user/emails": + emailRequests++ + http.Error(w, "scope required", http.StatusForbidden) + } + })) + defer server.Close() + + provider := newGitHubProvider("github", configauth.ProviderConfig{ + ClientID: "id", ClientSecret: "secret", RedirectURL: "https://admin.example/callback", Scopes: []string{"read:user"}, + }, githubEndpoints{OAuth: oauth2.Endpoint{TokenURL: server.URL + "/token"}, APIBaseURL: server.URL}, server.Client()) + principal, err := provider.Authenticate(context.Background(), "code", "verifier", "") + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if principal.Email != "" || emailRequests != 0 { + t.Fatalf("email = %q, email endpoint requests = %d", principal.Email, emailRequests) + } +} diff --git a/pkg/console/auth/middleware.go b/pkg/console/auth/middleware.go new file mode 100644 index 000000000..c45a9f69c --- /dev/null +++ b/pkg/console/auth/middleware.go @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/console/model" +) + +// SessionMiddleware loads an authenticated principal when the request has a valid Admin session. +func SessionMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + principal, err := PrincipalFromSession(sessions.Default(ctx)) + if err == nil { + PutPrincipalInContext(ctx, principal) + } + ctx.Next() + } +} + +// RequireLogin rejects requests that do not have an authenticated principal. +func RequireLogin() gin.HandlerFunc { + return func(ctx *gin.Context) { + if _, ok := PrincipalFromContext(ctx); !ok { + authErr := bizerror.New(bizerror.Unauthorized, "no access, please login") + ctx.JSON(http.StatusUnauthorized, model.NewBizErrorResp(authErr)) + ctx.Abort() + return + } + ctx.Next() + } +} diff --git a/pkg/console/auth/middleware_test.go b/pkg/console/auth/middleware_test.go new file mode 100644 index 000000000..051dec4bc --- /dev/null +++ b/pkg/console/auth/middleware_test.go @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +func TestSessionMiddlewareAndRequireLoginHaveSeparateResponsibilities(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + r.Use(SessionMiddleware()) + + r.GET("/public", func(c *gin.Context) { + if _, ok := PrincipalFromContext(c); ok { + t.Fatal("anonymous request unexpectedly has a principal") + } + c.Status(http.StatusNoContent) + }) + r.POST("/login", func(c *gin.Context) { + session := sessions.Default(c) + if err := PutPrincipal(session, LocalPrincipal("admin")); err != nil { + t.Fatal(err) + } + if err := session.Save(); err != nil { + t.Fatal(err) + } + c.Status(http.StatusNoContent) + }) + protected := r.Group("/protected") + protected.Use(RequireLogin()) + protected.GET("", func(c *gin.Context) { + principal, ok := PrincipalFromContext(c) + if !ok || principal.Username != "admin" { + t.Fatalf("principal = %+v, found = %v", principal, ok) + } + c.Status(http.StatusNoContent) + }) + + assertMiddlewareStatus(t, r, http.MethodGet, "/public", nil, http.StatusNoContent) + assertMiddlewareStatus(t, r, http.MethodGet, "/protected", nil, http.StatusUnauthorized) + + login := httptest.NewRecorder() + r.ServeHTTP(login, httptest.NewRequest(http.MethodPost, "/login", nil)) + if login.Code != http.StatusNoContent || len(login.Result().Cookies()) == 0 { + t.Fatalf("login status = %d, cookies = %v", login.Code, login.Result().Cookies()) + } + assertMiddlewareStatus(t, r, http.MethodGet, "/protected", login.Result().Cookies()[0], http.StatusNoContent) +} + +func assertMiddlewareStatus(t *testing.T, router http.Handler, method, path string, cookie *http.Cookie, want int) { + t.Helper() + req := httptest.NewRequest(method, path, nil) + if cookie != nil { + req.AddCookie(cookie) + } + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + if recorder.Code != want { + t.Fatalf("%s %s status = %d, want %d; body = %s", method, path, recorder.Code, want, recorder.Body.String()) + } +} diff --git a/pkg/console/auth/oidc.go b/pkg/console/auth/oidc.go new file mode 100644 index 000000000..38818d071 --- /dev/null +++ b/pkg/console/auth/oidc.go @@ -0,0 +1,275 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + "github.com/coreos/go-oidc/v3/oidc" + jose "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" + "golang.org/x/oauth2" +) + +const oidcHTTPTimeout = 10 * time.Second + +type oidcDiscovery struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + UserInfoEndpoint string `json:"userinfo_endpoint"` +} + +type oidcEndpoint struct { + name string + value string + required bool +} + +type oidcProfile struct { + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + Email string `json:"email"` + Groups []string `json:"groups"` + Roles []string `json:"roles"` + AuthorizedParty string `json:"azp"` +} + +type oidcProvider struct { + id string + displayName string + clientID string + postLoginRedirectURL string + oauth oauth2.Config + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + httpClient *http.Client + jwksURI string +} + +func NewOIDCProvider(ctx context.Context, id string, cfg configauth.ProviderConfig, client *http.Client) (Provider, error) { + if err := validateOIDCEndpoint("issuer", cfg.Issuer, true); err != nil { + return nil, fmt.Errorf("OIDC provider %q: %w", id, err) + } + if client == nil { + client = &http.Client{Timeout: oidcHTTPTimeout} + } + discoveryContext := context.WithValue(ctx, oauth2.HTTPClient, client) + discoveredProvider, err := oidc.NewProvider(discoveryContext, cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("discover OIDC provider %q: %w", id, err) + } + var discovery oidcDiscovery + if err := discoveredProvider.Claims(&discovery); err != nil { + return nil, fmt.Errorf("decode OIDC provider %q discovery: %w", id, err) + } + endpoints := []oidcEndpoint{ + {name: "authorization endpoint", value: discovery.AuthorizationEndpoint, required: true}, + {name: "token endpoint", value: discovery.TokenEndpoint, required: true}, + {name: "JWKS endpoint", value: discovery.JWKSURI, required: true}, + {name: "UserInfo endpoint", value: discovery.UserInfoEndpoint}, + } + for _, endpoint := range endpoints { + if err := validateOIDCEndpoint(endpoint.name, endpoint.value, endpoint.required); err != nil { + return nil, fmt.Errorf("OIDC provider %q: %w", id, err) + } + } + + provider := &oidcProvider{ + id: id, displayName: cfg.DisplayName, clientID: cfg.ClientID, + postLoginRedirectURL: cfg.PostLoginRedirectURL, provider: discoveredProvider, httpClient: client, + jwksURI: discovery.JWKSURI, + } + provider.oauth = oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, RedirectURL: cfg.RedirectURL, + Scopes: append([]string(nil), cfg.Scopes...), Endpoint: discoveredProvider.Endpoint(), + } + provider.verifier = discoveredProvider.Verifier(&oidc.Config{ + ClientID: cfg.ClientID, SupportedSigningAlgs: []string{oidc.RS256}, + }) + return provider, nil +} + +func (p *oidcProvider) ID() string { return p.id } +func (p *oidcProvider) DisplayName() string { return p.displayName } +func (p *oidcProvider) NeedsNonce() bool { return true } +func (p *oidcProvider) PostLoginRedirectURL() string { return p.postLoginRedirectURL } +func (p *oidcProvider) AuthorizationURL(transaction OAuthTransaction) string { + return p.oauth.AuthCodeURL(transaction.State, + oauth2.SetAuthURLParam("code_challenge", PKCEChallenge(transaction.CodeVerifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + oauth2.SetAuthURLParam("nonce", transaction.Nonce)) +} + +func (p *oidcProvider) Authenticate(ctx context.Context, code, codeVerifier, nonce string) (Principal, error) { + ctx = context.WithValue(ctx, oauth2.HTTPClient, p.httpClient) + token, err := p.oauth.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + if err != nil { + return Principal{}, fmt.Errorf("exchange OIDC authorization code: %w", err) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return Principal{}, errors.New("OIDC token response is missing id_token") + } + if err := p.validateJWKAlgorithm(ctx, rawIDToken); err != nil { + return Principal{}, err + } + idToken, err := p.verifier.Verify(ctx, rawIDToken) + if err != nil { + return Principal{}, fmt.Errorf("verify OIDC ID Token signature, issuer, audience, or expiration: %w", err) + } + var profile oidcProfile + if err := idToken.Claims(&profile); err != nil { + return Principal{}, fmt.Errorf("decode OIDC ID Token claims: %w", err) + } + if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(nonce)) != 1 { + return Principal{}, errors.New("OIDC ID Token nonce does not match OAuth transaction") + } + if idToken.Subject == "" { + return Principal{}, errors.New("OIDC ID Token subject is missing") + } + if len(idToken.Audience) > 1 && profile.AuthorizedParty == "" { + return Principal{}, errors.New("OIDC ID Token authorized party is required for multiple audiences") + } + if profile.AuthorizedParty != "" && profile.AuthorizedParty != p.clientID { + return Principal{}, errors.New("OIDC ID Token authorized party does not match client ID") + } + if profile.Email == "" && p.provider.UserInfoEndpoint() != "" { + userInfo, err := p.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + if err != nil { + return Principal{}, fmt.Errorf("read OIDC UserInfo: %w", err) + } + if userInfo.Subject != idToken.Subject { + return Principal{}, errors.New("OIDC UserInfo subject does not match ID Token subject") + } + var fallback oidcProfile + if err := userInfo.Claims(&fallback); err != nil { + return Principal{}, fmt.Errorf("decode OIDC UserInfo claims: %w", err) + } + mergeOIDCProfile(&profile, fallback) + } + return Principal{ + Subject: p.id + ":" + idToken.Subject, Username: oidcUsername(profile, idToken.Subject), Email: profile.Email, + Groups: nonNilStrings(profile.Groups), Roles: nonNilStrings(profile.Roles), AuthType: "oidc", Provider: p.id, + }, nil +} + +func (p *oidcProvider) validateJWKAlgorithm(ctx context.Context, rawIDToken string) error { + token, err := josejwt.ParseSigned(rawIDToken, []jose.SignatureAlgorithm{jose.RS256}) + if err != nil { + return fmt.Errorf("parse OIDC ID Token header: %w", err) + } + if len(token.Headers) != 1 || token.Headers[0].KeyID == "" { + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.jwksURI, nil) + if err != nil { + return fmt.Errorf("create OIDC JWKS request: %w", err) + } + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("read OIDC JWKS: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("read OIDC JWKS: endpoint returned HTTP %d", resp.StatusCode) + } + var keySet jose.JSONWebKeySet + if err := json.NewDecoder(resp.Body).Decode(&keySet); err != nil { + return fmt.Errorf("decode OIDC JWKS: %w", err) + } + keys := keySet.Key(token.Headers[0].KeyID) + for _, key := range keys { + if key.Algorithm == "" || key.Algorithm == string(jose.RS256) { + return nil + } + } + if len(keys) > 0 { + return errors.New("OIDC ID Token signing key declares an algorithm other than RS256") + } + return nil +} + +func validateOIDCEndpoint(name, raw string, required bool) error { + if raw == "" { + if required { + return fmt.Errorf("%s is missing", name) + } + return nil + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return fmt.Errorf("%s is not an absolute URL", name) + } + if parsed.Scheme == "https" || parsed.Scheme == "http" && oidcLoopbackHost(parsed.Hostname()) { + return nil + } + return fmt.Errorf("%s must use HTTPS, except for an HTTP loopback development endpoint", name) +} + +func oidcLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func oidcUsername(profile oidcProfile, subject string) string { + for _, candidate := range []string{profile.PreferredUsername, profile.Name, profile.Email, subject} { + if candidate != "" { + return candidate + } + } + return "" +} + +func mergeOIDCProfile(target *oidcProfile, fallback oidcProfile) { + if target.PreferredUsername == "" { + target.PreferredUsername = fallback.PreferredUsername + } + if target.Name == "" { + target.Name = fallback.Name + } + if target.Email == "" { + target.Email = fallback.Email + } + if len(target.Groups) == 0 { + target.Groups = fallback.Groups + } + if len(target.Roles) == 0 { + target.Roles = fallback.Roles + } +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} diff --git a/pkg/console/auth/oidc_test.go b/pkg/console/auth/oidc_test.go new file mode 100644 index 000000000..b3803136a --- /dev/null +++ b/pkg/console/auth/oidc_test.go @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + jose "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" +) + +func TestOIDCProviderVerifiesTokenAndUsesUserInfo(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + const kid = "oidc-key" + var server *httptest.Server + var tokenNonce = "expected-nonce" + var codeVerifier string + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": server.URL, "authorization_endpoint": server.URL + "/authorize", "token_endpoint": server.URL + "/token", + "jwks_uri": server.URL + "/jwks", "userinfo_endpoint": server.URL + "/userinfo", + }) + case "/jwks": + _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: kid, Algorithm: string(jose.RS256), Use: "sig"}}}) + case "/token": + _ = r.ParseForm() + codeVerifier = r.Form.Get("code_verifier") + token := signOIDCTestToken(t, key, kid, map[string]any{ + "iss": server.URL, "sub": "subject-1", "aud": "client-id", "exp": time.Now().Add(time.Minute).Unix(), + "iat": time.Now().Add(-time.Second).Unix(), "nonce": tokenNonce, + }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "provider-token", "token_type": "bearer", "id_token": token}) + case "/userinfo": + if got := r.Header.Get("Authorization"); got != "Bearer provider-token" { + t.Errorf("Authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "subject-1", "preferred_username": "alice", "email": "alice@example.com", "groups": []string{"engineering"}, "roles": []string{"operator"}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := NewOIDCProvider(context.Background(), "sso", configauth.ProviderConfig{ + Type: configauth.ProviderTypeOIDC, DisplayName: "SSO", Issuer: server.URL, ClientID: "client-id", ClientSecret: "secret", + RedirectURL: "https://admin.example/api/v1/auth/providers/sso/callback", PostLoginRedirectURL: "https://admin.example/admin/", Scopes: []string{"openid", "profile", "email"}, + }, server.Client()) + if err != nil { + t.Fatalf("NewOIDCProvider() error = %v", err) + } + principal, err := provider.Authenticate(context.Background(), "valid-code", "pkce-verifier", tokenNonce) + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if codeVerifier != "pkce-verifier" { + t.Fatalf("code_verifier = %q", codeVerifier) + } + if principal.Subject != "sso:subject-1" || principal.Username != "alice" || principal.Email != "alice@example.com" || principal.AuthType != "oidc" || len(principal.Groups) != 1 || len(principal.Roles) != 1 { + t.Fatalf("principal = %+v", principal) + } +} + +func TestOIDCProviderRejectsInvalidTokenClaims(t *testing.T) { + tests := []struct { + name string + mutate func(claims map[string]any) + wantError string + }{ + {name: "issuer", mutate: func(c map[string]any) { c["iss"] = "https://wrong.example" }, wantError: "issuer"}, + {name: "audience", mutate: func(c map[string]any) { c["aud"] = "wrong" }, wantError: "audience"}, + {name: "missing authorized party", mutate: func(c map[string]any) { + c["aud"] = []string{"client-id", "another-client"} + }, wantError: "authorized party"}, + {name: "authorized party", mutate: func(c map[string]any) { + c["aud"] = []string{"client-id", "another-client"} + c["azp"] = "another-client" + }, wantError: "authorized party"}, + {name: "expiration", mutate: func(c map[string]any) { c["exp"] = time.Now().Add(-time.Minute).Unix() }, wantError: "expired"}, + {name: "nonce", mutate: func(c map[string]any) { c["nonce"] = "wrong" }, wantError: "nonce"}, + {name: "subject", mutate: func(c map[string]any) { c["sub"] = "" }, wantError: "subject"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, cleanup := newOIDCTestProvider(t, tt.mutate, "subject-1") + defer cleanup() + _, err := provider.Authenticate(context.Background(), "code", "verifier", "expected-nonce") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), tt.wantError) { + t.Fatalf("Authenticate() error = %v, want containing %q", err, tt.wantError) + } + }) + } +} + +func TestOIDCProviderAcceptsMatchingAuthorizedPartyForMultipleAudiences(t *testing.T) { + provider, cleanup := newOIDCTestProvider(t, func(claims map[string]any) { + claims["aud"] = []string{"client-id", "another-client"} + claims["azp"] = "client-id" + }, "subject-1") + defer cleanup() + if _, err := provider.Authenticate(context.Background(), "code", "verifier", "expected-nonce"); err != nil { + t.Fatalf("Authenticate() error = %v", err) + } +} + +func TestOIDCProviderAcceptsJWKWithoutOptionalAlgorithm(t *testing.T) { + provider, cleanup := newOIDCTestProviderWithJWKAlgorithm(t, func(map[string]any) {}, "subject-1", "") + defer cleanup() + if _, err := provider.Authenticate(context.Background(), "code", "verifier", "expected-nonce"); err != nil { + t.Fatalf("Authenticate() error = %v", err) + } +} + +func TestOIDCProviderRejectsJWKWithConflictingAlgorithm(t *testing.T) { + provider, cleanup := newOIDCTestProviderWithJWKAlgorithm(t, func(map[string]any) {}, "subject-1", string(jose.RS512)) + defer cleanup() + if _, err := provider.Authenticate(context.Background(), "code", "verifier", "expected-nonce"); err == nil || !strings.Contains(err.Error(), "RS256") { + t.Fatalf("Authenticate() error = %v, want RS256 error", err) + } +} + +func TestOIDCProviderRejectsInsecureDiscoveredEndpoint(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": server.URL, "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": "http://sso.example/token", "jwks_uri": server.URL + "/jwks", + }) + })) + defer server.Close() + _, err := NewOIDCProvider(context.Background(), "sso", configauth.ProviderConfig{ + Issuer: server.URL, ClientID: "client-id", ClientSecret: "secret", RedirectURL: "https://admin.example/callback", Scopes: []string{"openid"}, + }, server.Client()) + if err == nil || !strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("NewOIDCProvider() error = %v, want HTTPS error", err) + } +} + +func TestOIDCProviderUsesBoundedDefaultHTTPClient(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": server.URL, "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", "jwks_uri": server.URL + "/jwks", + }) + })) + defer server.Close() + provider, err := NewOIDCProvider(context.Background(), "sso", configauth.ProviderConfig{ + Issuer: server.URL, ClientID: "client-id", ClientSecret: "secret", RedirectURL: "https://admin.example/callback", Scopes: []string{"openid"}, + }, nil) + if err != nil { + t.Fatalf("NewOIDCProvider() error = %v", err) + } + if timeout := provider.(*oidcProvider).httpClient.Timeout; timeout <= 0 { + t.Fatalf("HTTP client timeout = %v, want a positive timeout", timeout) + } +} + +func TestOIDCProviderRejectsUserInfoSubjectMismatch(t *testing.T) { + provider, cleanup := newOIDCTestProvider(t, func(map[string]any) {}, "other-subject") + defer cleanup() + _, err := provider.Authenticate(context.Background(), "code", "verifier", "expected-nonce") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "subject") { + t.Fatalf("Authenticate() error = %v", err) + } +} + +func newOIDCTestProvider(t *testing.T, mutate func(map[string]any), userInfoSubject string) (Provider, func()) { + return newOIDCTestProviderWithJWKAlgorithm(t, mutate, userInfoSubject, string(jose.RS256)) +} + +func newOIDCTestProviderWithJWKAlgorithm(t *testing.T, mutate func(map[string]any), userInfoSubject, algorithm string) (Provider, func()) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": server.URL, "authorization_endpoint": server.URL + "/authorize", "token_endpoint": server.URL + "/token", "jwks_uri": server.URL + "/jwks", "userinfo_endpoint": server.URL + "/userinfo"}) + case "/jwks": + _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "kid", Algorithm: algorithm, Use: "sig"}}}) + case "/token": + claims := map[string]any{"iss": server.URL, "sub": "subject-1", "aud": "client-id", "exp": time.Now().Add(time.Minute).Unix(), "iat": time.Now().Add(-time.Second).Unix(), "nonce": "expected-nonce"} + mutate(claims) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "provider-token", "token_type": "bearer", "id_token": signOIDCTestToken(t, key, "kid", claims)}) + case "/userinfo": + _ = json.NewEncoder(w).Encode(map[string]any{"sub": userInfoSubject, "preferred_username": "alice", "email": "alice@example.com"}) + } + })) + provider, err := NewOIDCProvider(context.Background(), "sso", configauth.ProviderConfig{Issuer: server.URL, ClientID: "client-id", ClientSecret: "secret", RedirectURL: "https://admin.example/callback", Scopes: []string{"openid"}}, server.Client()) + if err != nil { + server.Close() + t.Fatalf("NewOIDCProvider() error = %v", err) + } + return provider, server.Close +} + +func signOIDCTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + options := (&jose.SignerOptions{}).WithType("JWT").WithHeader(jose.HeaderKey("kid"), kid) + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, options) + if err != nil { + t.Fatal(err) + } + token, err := josejwt.Signed(signer).Claims(claims).Serialize() + if err != nil { + t.Fatal(err) + } + return token +} diff --git a/pkg/console/auth/principal.go b/pkg/console/auth/principal.go new file mode 100644 index 000000000..a84c67442 --- /dev/null +++ b/pkg/console/auth/principal.go @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "errors" + + "github.com/gin-gonic/gin" +) + +const principalContextKey = "dubbo-admin.auth.principal" + +var ErrNoPrincipal = errors.New("authenticated principal is missing") + +type Principal struct { + Subject string `json:"subject"` + Username string `json:"username"` + Email string `json:"email"` + Groups []string `json:"groups"` + Roles []string `json:"roles"` + AuthType string `json:"authType"` + Provider string `json:"provider"` +} + +func LocalPrincipal(username string) Principal { + return Principal{ + Subject: "local:" + username, + Username: username, + Groups: []string{}, + Roles: []string{}, + AuthType: "password", + Provider: "local", + } +} + +func PutPrincipalInContext(ctx *gin.Context, principal Principal) { + ctx.Set(principalContextKey, principal) +} + +func PrincipalFromContext(ctx *gin.Context) (Principal, bool) { + value, ok := ctx.Get(principalContextKey) + if !ok { + return Principal{}, false + } + principal, ok := value.(Principal) + return principal, ok +} diff --git a/pkg/console/auth/provider.go b/pkg/console/auth/provider.go new file mode 100644 index 000000000..2cd258a51 --- /dev/null +++ b/pkg/console/auth/provider.go @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import "context" + +type Provider interface { + ID() string + DisplayName() string + NeedsNonce() bool + PostLoginRedirectURL() string + AuthorizationURL(transaction OAuthTransaction) string + Authenticate(ctx context.Context, code, codeVerifier, nonce string) (Principal, error) +} + +type PublicProvider struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} diff --git a/pkg/console/auth/provider_test.go b/pkg/console/auth/provider_test.go new file mode 100644 index 000000000..b450c36ad --- /dev/null +++ b/pkg/console/auth/provider_test.go @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +type stubProvider struct { + id string + displayName string + needsNonce bool + authenticated int +} + +func (p *stubProvider) ID() string { return p.id } +func (p *stubProvider) DisplayName() string { return p.displayName } +func (p *stubProvider) NeedsNonce() bool { return p.needsNonce } +func (p *stubProvider) PostLoginRedirectURL() string { return "https://admin.example/admin/" } +func (p *stubProvider) AuthorizationURL(transaction OAuthTransaction) string { + return "https://provider.example/authorize?state=" + url.QueryEscape(transaction.State) + "&code_challenge=" + url.QueryEscape(PKCEChallenge(transaction.CodeVerifier)) + "&nonce=" + url.QueryEscape(transaction.Nonce) +} +func (p *stubProvider) Authenticate(_ context.Context, _, _, _ string) (Principal, error) { + p.authenticated++ + return Principal{Subject: p.id + ":1"}, nil +} + +func TestServicePublicProvidersAreSorted(t *testing.T) { + service, err := newService([]Provider{ + &stubProvider{id: "zeta", displayName: "Zeta"}, + &stubProvider{id: "alpha", displayName: "Alpha"}, + }) + if err != nil { + t.Fatal(err) + } + providers := service.PublicProviders() + if len(providers) != 2 || providers[0].ID != "alpha" || providers[1].ID != "zeta" { + t.Fatalf("PublicProviders() = %+v", providers) + } +} + +func TestServicePublicProvidersSerializesEmptyListAsArray(t *testing.T) { + service, err := newService(nil) + if err != nil { + t.Fatal(err) + } + providers := service.PublicProviders() + if providers == nil { + t.Fatal("PublicProviders() returned nil, want an empty slice") + } + raw, err := json.Marshal(providers) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if string(raw) != "[]" { + t.Fatalf("JSON = %s, want []", raw) + } +} + +func TestServiceBeginGeneratesStatePKCEAndOIDCNonce(t *testing.T) { + service, _ := newService([]Provider{&stubProvider{id: "sso", needsNonce: true}}) + transaction, authorizationURL, err := service.Begin("sso") + if err != nil { + t.Fatalf("Begin() error = %v", err) + } + if transaction.ProviderID != "sso" || len(transaction.State) < 43 || len(transaction.CodeVerifier) < 43 || len(transaction.Nonce) < 43 { + t.Fatalf("transaction = %+v", transaction) + } + if strings.Contains(authorizationURL, transaction.CodeVerifier) { + t.Fatalf("authorization URL leaked verifier: %s", authorizationURL) + } + parsed, _ := url.Parse(authorizationURL) + if got := parsed.Query().Get("code_challenge"); got != PKCEChallenge(transaction.CodeVerifier) { + t.Fatalf("code_challenge = %q", got) + } +} + +func TestServiceCompleteRejectsProviderAndStateBeforeAuthentication(t *testing.T) { + provider := &stubProvider{id: "github"} + service, _ := newService([]Provider{provider}) + transaction := OAuthTransaction{ProviderID: "github", State: "expected", CodeVerifier: "verifier"} + + if _, err := service.Complete(context.Background(), "other", "expected", "code", transaction); !errors.Is(err, ErrProviderMismatch) { + t.Fatalf("provider mismatch error = %v", err) + } + if _, err := service.Complete(context.Background(), "github", "wrong", "code", transaction); !errors.Is(err, ErrStateMismatch) { + t.Fatalf("state mismatch error = %v", err) + } + if provider.authenticated != 0 { + t.Fatalf("provider authenticated %d times", provider.authenticated) + } +} + +func TestServiceUnknownProvider(t *testing.T) { + service, _ := newService(nil) + if _, _, err := service.Begin("missing"); !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("Begin() error = %v", err) + } +} + +func TestOAuthTransactionStoreConsumesStateAtomically(t *testing.T) { + store := newOAuthTransactionStore(time.Minute, 2) + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "state", CodeVerifier: "verifier"}); err != nil { + t.Fatal(err) + } + + var wait sync.WaitGroup + results := make(chan bool, 2) + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + _, ok := store.Consume("state") + results <- ok + }() + } + wait.Wait() + close(results) + successes := 0 + for ok := range results { + if ok { + successes++ + } + } + if successes != 1 { + t.Fatalf("successful consumes = %d, want 1", successes) + } +} + +func TestOAuthTransactionStoreRejectsExpiredState(t *testing.T) { + now := time.Now() + store := newOAuthTransactionStore(time.Minute, 2) + store.now = func() time.Time { return now } + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "state"}); err != nil { + t.Fatal(err) + } + now = now.Add(time.Minute) + if _, ok := store.Consume("state"); ok { + t.Fatal("Consume() accepted an expired transaction") + } +} + +func TestOAuthTransactionStoreRejectsNewTransactionAtCapacity(t *testing.T) { + store := newOAuthTransactionStore(time.Minute, 2) + for _, state := range []string{"first", "second"} { + if err := store.Put(OAuthTransaction{ProviderID: "github", State: state}); err != nil { + t.Fatalf("Put(%q) error = %v", state, err) + } + } + + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "third"}); !errors.Is(err, ErrTransactionStoreFull) { + t.Fatalf("Put() error = %v, want ErrTransactionStoreFull", err) + } + if _, ok := store.Consume("first"); !ok { + t.Fatal("capacity rejection evicted an active transaction") + } + if _, ok := store.Consume("third"); ok { + t.Fatal("capacity rejection retained the rejected transaction") + } +} + +func TestOAuthTransactionStoreReclaimsExpiredCapacity(t *testing.T) { + now := time.Now() + store := newOAuthTransactionStore(time.Minute, 1) + store.now = func() time.Time { return now } + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "expired"}); err != nil { + t.Fatal(err) + } + + now = now.Add(time.Minute) + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "current"}); err != nil { + t.Fatalf("Put() after expiry error = %v", err) + } + if _, ok := store.Consume("current"); !ok { + t.Fatal("new transaction was not retained") + } +} + +func TestOAuthTransactionStoreConsumeRemovesOrderEntry(t *testing.T) { + store := newOAuthTransactionStore(time.Minute, 1) + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "first"}); err != nil { + t.Fatal(err) + } + if _, ok := store.Consume("first"); !ok { + t.Fatal("Consume() did not find transaction") + } + if got := store.expiryQueue.Len(); got != 0 { + t.Fatalf("expiry queue length = %d, want 0", got) + } + if err := store.Put(OAuthTransaction{ProviderID: "github", State: "second"}); err != nil { + t.Fatalf("Put() after consume error = %v", err) + } +} diff --git a/pkg/console/auth/service.go b/pkg/console/auth/service.go new file mode 100644 index 000000000..2a2daa889 --- /dev/null +++ b/pkg/console/auth/service.go @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "container/list" + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "net/http" + "sort" + "sync" + "time" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" +) + +var ( + ErrProviderNotFound = errors.New("authentication provider not found") + ErrProviderMismatch = errors.New("OAuth callback provider does not match transaction") + ErrStateMismatch = errors.New("OAuth callback state does not match transaction") + ErrTransactionGone = errors.New("OAuth transaction is missing, expired, or already consumed") + ErrTransactionStoreFull = errors.New("OAuth transaction store is full") +) + +const ( + oauthTransactionTTL = 10 * time.Minute + oauthTransactionCapacity = 10_000 +) + +// Service coordinates provider registration and the shared OAuth callback flow. +type Service struct { + providers map[string]Provider + public []PublicProvider + transactions *oauthTransactionStore +} + +func NewService(ctx context.Context, configs map[string]configauth.ProviderConfig, client *http.Client) (*Service, error) { + ids := make([]string, 0, len(configs)) + for id := range configs { + ids = append(ids, id) + } + sort.Strings(ids) + providers := make([]Provider, 0, len(ids)) + for _, id := range ids { + cfg := configs[id] + switch cfg.Type { + case configauth.ProviderTypeGitHub: + providers = append(providers, NewGitHubProvider(id, cfg)) + case configauth.ProviderTypeOIDC: + provider, err := NewOIDCProvider(ctx, id, cfg, client) + if err != nil { + return nil, err + } + providers = append(providers, provider) + default: + return nil, fmt.Errorf("unsupported authentication provider type %q", cfg.Type) + } + } + return newService(providers) +} + +func newService(providers []Provider) (*Service, error) { + service := &Service{ + providers: make(map[string]Provider, len(providers)), + transactions: newOAuthTransactionStore(oauthTransactionTTL, oauthTransactionCapacity), + } + for _, provider := range providers { + if provider == nil || provider.ID() == "" { + return nil, errors.New("authentication provider must have an ID") + } + if _, exists := service.providers[provider.ID()]; exists { + return nil, fmt.Errorf("duplicate authentication provider %q", provider.ID()) + } + service.providers[provider.ID()] = provider + service.public = append(service.public, PublicProvider{ID: provider.ID(), DisplayName: provider.DisplayName()}) + } + sort.Slice(service.public, func(i, j int) bool { return service.public[i].ID < service.public[j].ID }) + return service, nil +} + +func NewServiceFromProviders(providers ...Provider) (*Service, error) { + return newService(providers) +} + +func (s *Service) PublicProviders() []PublicProvider { + return append([]PublicProvider{}, s.public...) +} + +func (s *Service) Begin(providerID string) (OAuthTransaction, string, error) { + provider, ok := s.providers[providerID] + if !ok { + return OAuthTransaction{}, "", ErrProviderNotFound + } + state, err := randomBase64URL(32) + if err != nil { + return OAuthTransaction{}, "", fmt.Errorf("generate OAuth state: %w", err) + } + verifier, err := randomBase64URL(32) + if err != nil { + return OAuthTransaction{}, "", fmt.Errorf("generate PKCE verifier: %w", err) + } + transaction := OAuthTransaction{ProviderID: providerID, State: state, CodeVerifier: verifier} + if provider.NeedsNonce() { + transaction.Nonce, err = randomBase64URL(32) + if err != nil { + return OAuthTransaction{}, "", fmt.Errorf("generate OIDC nonce: %w", err) + } + } + if err := s.transactions.Put(transaction); err != nil { + return OAuthTransaction{}, "", err + } + return transaction, provider.AuthorizationURL(transaction), nil +} + +func (s *Service) Complete(ctx context.Context, providerID, state, code string, transaction OAuthTransaction) (Principal, error) { + if providerID != transaction.ProviderID { + return Principal{}, ErrProviderMismatch + } + if subtle.ConstantTimeCompare([]byte(state), []byte(transaction.State)) != 1 { + return Principal{}, ErrStateMismatch + } + storedTransaction, ok := s.transactions.Consume(state) + if !ok { + return Principal{}, ErrTransactionGone + } + if providerID != storedTransaction.ProviderID { + return Principal{}, ErrProviderMismatch + } + provider, ok := s.providers[providerID] + if !ok { + return Principal{}, ErrProviderNotFound + } + if code == "" { + return Principal{}, errors.New("OAuth callback code is missing") + } + return provider.Authenticate(ctx, code, storedTransaction.CodeVerifier, storedTransaction.Nonce) +} + +func (s *Service) PostLoginRedirectURL(providerID string) (string, error) { + provider, ok := s.providers[providerID] + if !ok { + return "", ErrProviderNotFound + } + return provider.PostLoginRedirectURL(), nil +} + +func PKCEChallenge(verifier string) string { + digest := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func randomBase64URL(size int) (string, error) { + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// TODO: can move it to a seperated file? +type storedOAuthTransaction struct { + transaction OAuthTransaction + expiresAt time.Time +} + +// TODO: move this store into redis? +type oauthTransactionStore struct { + mu sync.Mutex + ttl time.Duration + capacity int + now func() time.Time + items map[string]*list.Element + expiryQueue *list.List +} + +func newOAuthTransactionStore(ttl time.Duration, capacity int) *oauthTransactionStore { + return &oauthTransactionStore{ + ttl: ttl, capacity: capacity, now: time.Now, + items: make(map[string]*list.Element), expiryQueue: list.New(), + } +} + +func (s *oauthTransactionStore) Put(transaction OAuthTransaction) error { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.removeExpired(now) + if existing, ok := s.items[transaction.State]; ok { + s.remove(existing) + } + if len(s.items) >= s.capacity { + return ErrTransactionStoreFull + } + element := s.expiryQueue.PushBack(storedOAuthTransaction{transaction: transaction, expiresAt: now.Add(s.ttl)}) + s.items[transaction.State] = element + return nil +} + +func (s *oauthTransactionStore) Consume(state string) (OAuthTransaction, bool) { + s.mu.Lock() + defer s.mu.Unlock() + element, ok := s.items[state] + if !ok { + return OAuthTransaction{}, false + } + stored := element.Value.(storedOAuthTransaction) + s.remove(element) + if !stored.expiresAt.After(s.now()) { + return OAuthTransaction{}, false + } + return stored.transaction, true +} + +func (s *oauthTransactionStore) removeExpired(now time.Time) { + for element := s.expiryQueue.Front(); element != nil; element = s.expiryQueue.Front() { + stored := element.Value.(storedOAuthTransaction) + if stored.expiresAt.After(now) { + return + } + s.remove(element) + } +} + +func (s *oauthTransactionStore) remove(element *list.Element) { + stored := element.Value.(storedOAuthTransaction) + delete(s.items, stored.transaction.State) + s.expiryQueue.Remove(element) +} diff --git a/pkg/console/auth/session.go b/pkg/console/auth/session.go new file mode 100644 index 000000000..92e10d921 --- /dev/null +++ b/pkg/console/auth/session.go @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/gin-contrib/sessions" +) + +const ( + PrincipalSessionKey = "principal" + LegacyUserSessionKey = "user" + OAuthTransactionSessionKey = "oauth_transaction" +) + +type OAuthTransaction struct { + ProviderID string `json:"providerId"` + State string `json:"state"` + CodeVerifier string `json:"codeVerifier"` + Nonce string `json:"nonce,omitempty"` +} + +func PutPrincipal(session sessions.Session, principal Principal) error { + raw, err := json.Marshal(principal) + if err != nil { + return fmt.Errorf("marshal principal: %w", err) + } + session.Set(PrincipalSessionKey, string(raw)) + session.Delete(LegacyUserSessionKey) + return nil +} + +func PrincipalFromSession(session sessions.Session) (Principal, error) { + if value := session.Get(PrincipalSessionKey); value != nil { + raw, ok := sessionJSONString(value) + if !ok { + return Principal{}, fmt.Errorf("invalid principal session value %T", value) + } + var principal Principal + if err := json.Unmarshal([]byte(raw), &principal); err != nil { + return Principal{}, fmt.Errorf("decode principal session: %w", err) + } + if principal.Subject == "" { + return Principal{}, ErrNoPrincipal + } + if principal.Groups == nil { + principal.Groups = []string{} + } + if principal.Roles == nil { + principal.Roles = []string{} + } + return principal, nil + } + legacyUser, ok := session.Get(LegacyUserSessionKey).(string) + if !ok || legacyUser == "" { + return Principal{}, ErrNoPrincipal + } + return LocalPrincipal(legacyUser), nil +} + +func PutOAuthTransaction(session sessions.Session, transaction OAuthTransaction) error { + reference := OAuthTransaction{ProviderID: transaction.ProviderID, State: transaction.State} + raw, err := json.Marshal(reference) + if err != nil { + return fmt.Errorf("marshal OAuth transaction: %w", err) + } + session.Set(OAuthTransactionSessionKey, string(raw)) + return nil +} + +func ConsumeOAuthTransaction(session sessions.Session) (OAuthTransaction, error) { + value := session.Get(OAuthTransactionSessionKey) + session.Delete(OAuthTransactionSessionKey) + if value == nil { + return OAuthTransaction{}, errors.New("OAuth transaction is missing or already consumed") + } + raw, ok := sessionJSONString(value) + if !ok { + return OAuthTransaction{}, fmt.Errorf("invalid OAuth transaction session value %T", value) + } + var transaction OAuthTransaction + if err := json.Unmarshal([]byte(raw), &transaction); err != nil { + return OAuthTransaction{}, fmt.Errorf("decode OAuth transaction: %w", err) + } + return transaction, nil +} + +func sessionJSONString(value any) (string, bool) { + switch typed := value.(type) { + case string: + return typed, true + case []byte: + return string(typed), true + default: + return "", false + } +} diff --git a/pkg/console/auth/session_test.go b/pkg/console/auth/session_test.go new file mode 100644 index 000000000..705e78a9e --- /dev/null +++ b/pkg/console/auth/session_test.go @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +func TestPrincipalSessionRoundTripUsesJSONString(t *testing.T) { + principal := Principal{ + Subject: "github:123", Username: "octocat", Email: "octo@example.com", + Groups: []string{"engineering"}, Roles: []string{"admin"}, AuthType: "oauth", Provider: "github", + } + var stored any + got := exerciseSession(t, func(c *gin.Context) { + session := sessions.Default(c) + if err := PutPrincipal(session, principal); err != nil { + t.Fatalf("PutPrincipal() error = %v", err) + } + stored = session.Get(PrincipalSessionKey) + parsed, err := PrincipalFromSession(session) + if err != nil { + t.Fatalf("PrincipalFromSession() error = %v", err) + } + c.JSON(http.StatusOK, parsed) + }) + if _, ok := stored.(string); !ok { + t.Fatalf("stored principal type = %T, want string", stored) + } + if got.Subject != principal.Subject || got.Username != principal.Username || len(got.Groups) != 1 { + t.Fatalf("round-trip principal = %+v", got) + } +} + +func TestPrincipalFromSessionMigratesLegacyUser(t *testing.T) { + got := exerciseSession(t, func(c *gin.Context) { + session := sessions.Default(c) + session.Set(LegacyUserSessionKey, "admin") + principal, err := PrincipalFromSession(session) + if err != nil { + t.Fatalf("PrincipalFromSession() error = %v", err) + } + c.JSON(http.StatusOK, principal) + }) + if got.Subject != "local:admin" || got.Username != "admin" || got.AuthType != "password" || got.Provider != "local" || len(got.Groups) != 0 || len(got.Roles) != 0 { + t.Fatalf("legacy principal = %+v", got) + } +} + +func TestOAuthTransactionIsConsumed(t *testing.T) { + exerciseSession(t, func(c *gin.Context) { + session := sessions.Default(c) + want := OAuthTransaction{ProviderID: "github", State: "state", CodeVerifier: "verifier", Nonce: "nonce"} + if err := PutOAuthTransaction(session, want); err != nil { + t.Fatalf("PutOAuthTransaction() error = %v", err) + } + got, err := ConsumeOAuthTransaction(session) + if err != nil || got.ProviderID != want.ProviderID || got.State != want.State || got.CodeVerifier != "" || got.Nonce != "" { + t.Fatalf("ConsumeOAuthTransaction() = %+v, %v; want only provider ID and state", got, err) + } + if _, err := ConsumeOAuthTransaction(session); err == nil { + t.Fatal("second ConsumeOAuthTransaction() succeeded, want replay rejection") + } + c.JSON(http.StatusOK, Principal{}) + }) +} + +func exerciseSession(t *testing.T, handler gin.HandlerFunc) Principal { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + r.GET("/", handler) + recorder := httptest.NewRecorder() + r.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var principal Principal + if err := json.Unmarshal(recorder.Body.Bytes(), &principal); err != nil { + t.Fatalf("decode response: %v", err) + } + return principal +} diff --git a/pkg/console/component.go b/pkg/console/component.go index c371ab908..6355fea3b 100644 --- a/pkg/console/component.go +++ b/pkg/console/component.go @@ -32,10 +32,10 @@ import ( "github.com/gin-gonic/gin" ui "github.com/apache/dubbo-admin/app/dubbo-ui" - "github.com/apache/dubbo-admin/pkg/common/bizerror" "github.com/apache/dubbo-admin/pkg/config/console" + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + consoleauth "github.com/apache/dubbo-admin/pkg/console/auth" consolectx "github.com/apache/dubbo-admin/pkg/console/context" - "github.com/apache/dubbo-admin/pkg/console/model" "github.com/apache/dubbo-admin/pkg/console/router" "github.com/apache/dubbo-admin/pkg/core/logger" "github.com/apache/dubbo-admin/pkg/core/runtime" @@ -83,9 +83,10 @@ func (c *consoleWebServer) Init(ctx runtime.BuilderContext) error { "status": "UP", }) }) - store := cookie.NewStore([]byte("secret")) + store := cookie.NewStore([]byte(c.cfg.Auth.SessionSecret)) + store.Options(adminSessionOptions(c.cfg.Auth)) r.Use(sessions.Sessions("session", store)) - r.Use(c.authMiddleware()) + r.Use(consoleauth.SessionMiddleware()) r.Use(ginzap.Ginzap(logger.Logger(), time.RFC3339, true)) r.Use(ginzap.RecoveryWithZap(logger.Logger(), true)) c.Engine = r @@ -93,10 +94,22 @@ func (c *consoleWebServer) Init(ctx runtime.BuilderContext) error { return nil } +func adminSessionOptions(cfg *configauth.Config) sessions.Options { + return sessions.Options{ + Path: "/", + MaxAge: cfg.ExpirationTime, + Secure: cfg.SessionCookieSecure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } +} + func (c *consoleWebServer) Start(coreRt runtime.Runtime, stop <-chan struct{}) error { errChan := make(chan error) c.cs = consolectx.NewConsoleContext(coreRt) - router.InitRouter(c.Engine, c.cs) + if err := router.InitRouter(c.Engine, c.cs); err != nil { + return err + } httpServer := c.startHttpServer(errChan) select { case <-stop: @@ -131,23 +144,3 @@ func (c *consoleWebServer) startHttpServer(errChan chan error) *http.Server { return server } - -func (c *consoleWebServer) authMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - // skip login api - requestPath := c.Request.URL.Path - if strings.HasSuffix(requestPath, "/login") { - c.Next() - return - } - session := sessions.Default(c) - user := session.Get("user") - if user == nil { - authErr := bizerror.New(bizerror.Unauthorized, "no access, please login") - c.JSON(http.StatusUnauthorized, model.NewBizErrorResp(authErr)) - c.Abort() - return - } - c.Next() - } -} diff --git a/pkg/console/handler/auth.go b/pkg/console/handler/auth.go index 1d2b7c1ab..60185275b 100644 --- a/pkg/console/handler/auth.go +++ b/pkg/console/handler/auth.go @@ -18,53 +18,161 @@ package handler import ( + "errors" "net/http" + "slices" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/apache/dubbo-admin/pkg/common/bizerror" + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + consoleauth "github.com/apache/dubbo-admin/pkg/console/auth" consolectx "github.com/apache/dubbo-admin/pkg/console/context" "github.com/apache/dubbo-admin/pkg/console/model" ) -func Login(ctx consolectx.Context) gin.HandlerFunc { - return func(c *gin.Context) { - user := c.PostForm("user") - password := c.PostForm("password") - // verify username and password - authCfg := ctx.Config().Console.Auth - if user != authCfg.User || password != authCfg.Password { - authErr := bizerror.New(bizerror.Unauthorized, "username or password is not correct!") - c.JSON(http.StatusUnauthorized, model.NewBizErrorResp(authErr)) - return - } - session := sessions.Default(c) - session.Set("user", user) - session.Options(sessions.Options{ - MaxAge: authCfg.ExpirationTime, - Path: "/", - }) - err := session.Save() - if err != nil { - sessionErr := bizerror.New(bizerror.SessionError, err.Error()) - c.JSON(http.StatusOK, model.NewBizErrorResp(sessionErr)) - return - } - c.JSON(http.StatusOK, model.NewSuccessResp(true)) +type AuthHandler struct { + config *configauth.Config + service *consoleauth.Service +} + +type providersResponse struct { + Methods []string `json:"methods"` + Providers []consoleauth.PublicProvider `json:"providers"` +} + +func NewAuthHandler(ctx consolectx.Context) (*AuthHandler, error) { + config := ctx.Config().Console.Auth + service, err := consoleauth.NewService(ctx.AppContext(), config.Providers, nil) + if err != nil { + return nil, err } + return newAuthHandler(config, service), nil +} + +func newAuthHandler(config *configauth.Config, service *consoleauth.Service) *AuthHandler { + return &AuthHandler{config: config, service: service} } -func Logout(_ consolectx.Context) gin.HandlerFunc { - return func(c *gin.Context) { - session := sessions.Default(c) - session.Clear() - err := session.Save() - if err != nil { - sessionErr := bizerror.New(bizerror.SessionError, err.Error()) - c.JSON(http.StatusOK, model.NewBizErrorResp(sessionErr)) - return - } - c.JSON(http.StatusOK, model.NewSuccessResp(true)) +func (h *AuthHandler) Login(c *gin.Context) { + if !slices.Contains(h.config.Methods, configauth.MethodPassword) { + c.JSON(http.StatusNotFound, model.NewBizErrorResp(bizerror.New(bizerror.NotFoundError, "password login is not enabled"))) + return + } + user := c.PostForm("user") + password := c.PostForm("password") + if user != h.config.User || password != h.config.Password { + c.JSON(http.StatusUnauthorized, model.NewBizErrorResp(bizerror.New(bizerror.Unauthorized, "username or password is not correct!"))) + return + } + session := sessions.Default(c) + if err := consoleauth.PutPrincipal(session, consoleauth.LocalPrincipal(user)); err != nil { + writeSessionError(c, err) + return + } + if err := session.Save(); err != nil { + writeSessionError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(true)) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + session.Options(sessions.Options{ + Path: "/", MaxAge: -1, Secure: h.config.SessionCookieSecure, HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) + if err := session.Save(); err != nil { + writeSessionError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(true)) +} + +func (h *AuthHandler) Providers(c *gin.Context) { + c.JSON(http.StatusOK, model.NewSuccessResp(providersResponse{ + Methods: append([]string{}, h.config.Methods...), Providers: h.service.PublicProviders(), + })) +} + +func (h *AuthHandler) ProviderLogin(c *gin.Context) { + transaction, authorizationURL, err := h.service.Begin(c.Param("provider")) + if err != nil { + writeProviderError(c, err) + return + } + session := sessions.Default(c) + if err := consoleauth.PutOAuthTransaction(session, transaction); err != nil { + writeSessionError(c, err) + return + } + if err := session.Save(); err != nil { + writeSessionError(c, err) + return } + c.Redirect(http.StatusFound, authorizationURL) +} + +func (h *AuthHandler) ProviderCallback(c *gin.Context) { + session := sessions.Default(c) + transaction, err := consoleauth.ConsumeOAuthTransaction(session) + if err != nil { + c.JSON(http.StatusBadRequest, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, err.Error()))) + return + } + // Clear the client-side transaction reference before contacting the Provider. + if err := session.Save(); err != nil { + writeSessionError(c, err) + return + } + principal, err := h.service.Complete(c.Request.Context(), c.Param("provider"), c.Query("state"), c.Query("code"), transaction) + if err != nil { + writeProviderError(c, err) + return + } + if err := consoleauth.PutPrincipal(session, principal); err != nil { + writeSessionError(c, err) + return + } + if err := session.Save(); err != nil { + writeSessionError(c, err) + return + } + redirectURL, err := h.service.PostLoginRedirectURL(transaction.ProviderID) + if err != nil { + writeProviderError(c, err) + return + } + c.Redirect(http.StatusFound, redirectURL) +} + +func (h *AuthHandler) UserInfo(c *gin.Context) { + principal, ok := consoleauth.PrincipalFromContext(c) + if !ok { + writeUnauthorized(c) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(principal)) +} + +func writeProviderError(c *gin.Context, err error) { + status := http.StatusBadRequest + code := bizerror.InvalidArgument + if errors.Is(err, consoleauth.ErrProviderNotFound) { + status = http.StatusNotFound + code = bizerror.NotFoundError + } else if errors.Is(err, consoleauth.ErrTransactionStoreFull) { + status = http.StatusTooManyRequests + } + c.JSON(status, model.NewBizErrorResp(bizerror.New(code, err.Error()))) +} + +func writeSessionError(c *gin.Context, err error) { + c.JSON(http.StatusInternalServerError, model.NewBizErrorResp(bizerror.New(bizerror.SessionError, err.Error()))) +} + +func writeUnauthorized(c *gin.Context) { + c.JSON(http.StatusUnauthorized, model.NewBizErrorResp(bizerror.NewUnauthorizedError())) } diff --git a/pkg/console/handler/auth_test.go b/pkg/console/handler/auth_test.go new file mode 100644 index 000000000..1aca1c8d5 --- /dev/null +++ b/pkg/console/handler/auth_test.go @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + consoleauth "github.com/apache/dubbo-admin/pkg/console/auth" +) + +type handlerProvider struct { + authenticated int +} + +func (p *handlerProvider) ID() string { return "github" } +func (p *handlerProvider) DisplayName() string { return "GitHub" } +func (p *handlerProvider) NeedsNonce() bool { return false } +func (p *handlerProvider) PostLoginRedirectURL() string { return "https://admin.example/admin/" } +func (p *handlerProvider) AuthorizationURL(tx consoleauth.OAuthTransaction) string { + return "https://provider.example/authorize?state=" + url.QueryEscape(tx.State) +} +func (p *handlerProvider) Authenticate(_ context.Context, _, _, _ string) (consoleauth.Principal, error) { + p.authenticated++ + return consoleauth.Principal{Subject: "github:123", Username: "octocat", Groups: []string{}, Roles: []string{}, AuthType: "oauth", Provider: "github"}, nil +} + +func TestAuthHandlerPasswordLoginAndUserInfo(t *testing.T) { + cfg := &configauth.Config{Methods: []string{configauth.MethodPassword}, User: "admin", Password: "secret", ExpirationTime: 3600} + service, _ := consoleauth.NewServiceFromProviders() + handler := newAuthHandler(cfg, service) + router := authTestRouter(handler) + + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader("user=admin&password=secret")) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginResp := httptest.NewRecorder() + router.ServeHTTP(loginResp, loginReq) + if loginResp.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", loginResp.Code, loginResp.Body.String()) + } + cookie := loginResp.Result().Cookies()[0] + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" || cookie.MaxAge != 3600 { + t.Fatalf("session cookie = %+v", cookie) + } + + userinfo := doAuthRequest(router, http.MethodGet, "/api/v1/auth/userinfo", cookie) + if userinfo.Code != http.StatusOK || !strings.Contains(userinfo.Body.String(), `"subject":"local:admin"`) || !strings.Contains(userinfo.Body.String(), `"authType":"password"`) { + t.Fatalf("userinfo status = %d, body = %s", userinfo.Code, userinfo.Body.String()) + } +} + +func TestAuthHandlerProviderListDoesNotLeakConfiguration(t *testing.T) { + cfg := &configauth.Config{Methods: []string{configauth.MethodPassword}, Providers: map[string]configauth.ProviderConfig{ + "github": {Type: configauth.ProviderTypeGitHub, DisplayName: "GitHub", ClientID: "client", ClientSecret: "top-secret", RedirectURL: "https://admin.example/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "https://admin.example/admin/", Scopes: []string{"read:user"}}, + }} + service, err := consoleauth.NewService(context.Background(), cfg.Providers, nil) + if err != nil { + t.Fatal(err) + } + handler := newAuthHandler(cfg, service) + router := authTestRouter(handler) + resp := doAuthRequest(router, http.MethodGet, "/api/v1/auth/providers", nil) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"id":"github"`) { + t.Fatalf("status = %d, body = %s", resp.Code, resp.Body.String()) + } + for _, secret := range []string{"top-secret", "client", "redirectUrl", "scopes", "issuer"} { + if strings.Contains(resp.Body.String(), secret) { + t.Fatalf("provider response leaked %q: %s", secret, resp.Body.String()) + } + } +} + +func TestAuthHandlerPasswordOnlyProviderListUsesEmptyArray(t *testing.T) { + service, err := consoleauth.NewServiceFromProviders() + if err != nil { + t.Fatal(err) + } + handler := newAuthHandler( + &configauth.Config{Methods: []string{configauth.MethodPassword}}, + service, + ) + resp := doAuthRequest(authTestRouter(handler), http.MethodGet, "/api/v1/auth/providers", nil) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"providers":[]`) { + t.Fatalf("status = %d, body = %s", resp.Code, resp.Body.String()) + } +} + +func TestAuthHandlerProviderOnlyMethodListUsesEmptyArray(t *testing.T) { + service, err := consoleauth.NewServiceFromProviders() + if err != nil { + t.Fatal(err) + } + handler := newAuthHandler(&configauth.Config{Methods: []string{}}, service) + resp := doAuthRequest(authTestRouter(handler), http.MethodGet, "/api/v1/auth/providers", nil) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"methods":[]`) { + t.Fatalf("status = %d, body = %s", resp.Code, resp.Body.String()) + } +} + +func TestAuthHandlerOAuthCallbackConsumesTransaction(t *testing.T) { + provider := &handlerProvider{} + service, _ := consoleauth.NewServiceFromProviders(provider) + cfg := &configauth.Config{Providers: map[string]configauth.ProviderConfig{"github": {}}} + handler := newAuthHandler(cfg, service) + router := authTestRouter(handler) + + login := doAuthRequest(router, http.MethodGet, "/api/v1/auth/providers/github/login", nil) + if login.Code != http.StatusFound { + t.Fatalf("login status = %d, body = %s", login.Code, login.Body.String()) + } + cookie := login.Result().Cookies()[0] + redirect, _ := url.Parse(login.Header().Get("Location")) + callbackURL := "/api/v1/auth/providers/github/callback?code=valid&state=" + url.QueryEscape(redirect.Query().Get("state")) + callback := doAuthRequest(router, http.MethodGet, callbackURL, cookie) + if callback.Code != http.StatusFound || callback.Header().Get("Location") != "https://admin.example/admin/" || provider.authenticated != 1 { + t.Fatalf("callback status = %d location = %q calls = %d body = %s", callback.Code, callback.Header().Get("Location"), provider.authenticated, callback.Body.String()) + } + // Replaying the original cookie must fail even though it still contains the transaction. + replay := doAuthRequest(router, http.MethodGet, callbackURL, cookie) + if replay.Code != http.StatusBadRequest || provider.authenticated != 1 { + t.Fatalf("replay status = %d calls = %d body = %s", replay.Code, provider.authenticated, replay.Body.String()) + } +} + +func TestWriteProviderErrorMapsFullTransactionStoreToTooManyRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + + writeProviderError(ctx, consoleauth.ErrTransactionStoreFull) + + if recorder.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusTooManyRequests) + } +} + +func authTestRouter(authHandler *AuthHandler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + store := cookie.NewStore([]byte("test-secret")) + store.Options(sessions.Options{Path: "/", MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + r.Use(sessions.Sessions("session", store)) + r.Use(consoleauth.SessionMiddleware()) + auth := r.Group("/api/v1/auth") + auth.POST("/login", authHandler.Login) + auth.POST("/logout", authHandler.Logout) + auth.GET("/providers", authHandler.Providers) + auth.GET("/providers/:provider/login", authHandler.ProviderLogin) + auth.GET("/providers/:provider/callback", authHandler.ProviderCallback) + auth.GET("/userinfo", authHandler.UserInfo) + return r +} + +func doAuthRequest(router http.Handler, method, path string, cookie *http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + if cookie != nil { + req.AddCookie(cookie) + } + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + return recorder +} diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go index 24cd7d44c..7a18ad214 100644 --- a/pkg/console/router/router.go +++ b/pkg/console/router/router.go @@ -20,25 +20,40 @@ package router import ( "github.com/gin-gonic/gin" + consoleauth "github.com/apache/dubbo-admin/pkg/console/auth" consolectx "github.com/apache/dubbo-admin/pkg/console/context" "github.com/apache/dubbo-admin/pkg/console/handler" ) -func InitRouter(r *gin.Engine, ctx consolectx.Context) { - router := r.Group("/api/v1") +func InitRouter(r *gin.Engine, ctx consolectx.Context) error { + authHandler, err := handler.NewAuthHandler(ctx) + if err != nil { + return err + } + api := r.Group("/api/v1") { - prometheus := router.Group("/promQL") - prometheus.GET("/query", handler.PromQL(ctx)) + auth := api.Group("/auth") + auth.POST("/login", authHandler.Login) + auth.GET("/providers", authHandler.Providers) + auth.GET("/providers/:provider/login", authHandler.ProviderLogin) + auth.GET("/providers/:provider/callback", authHandler.ProviderCallback) + } + + protected := api.Group("") + protected.Use(consoleauth.RequireLogin()) + { + auth := protected.Group("/auth") + auth.POST("/logout", authHandler.Logout) + auth.GET("/userinfo", authHandler.UserInfo) } { - auth := router.Group("/auth") - auth.POST("/login", handler.Login(ctx)) - auth.POST("/logout", handler.Logout(ctx)) + prometheus := protected.Group("/promQL") + prometheus.GET("/query", handler.PromQL(ctx)) } { - instance := router.Group("/instance") + instance := protected.Group("/instance") instance.GET("/search", handler.SearchInstances(ctx)) instance.GET("/detail", handler.GetInstanceDetail(ctx)) { @@ -55,7 +70,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { } { - application := router.Group("/application") + application := protected.Group("/application") application.GET("/detail", handler.GetApplicationDetail(ctx)) application.GET("/instance/info", handler.GetApplicationTabInstanceInfo(ctx)) application.GET("/service/form", handler.GetApplicationServiceForm(ctx)) @@ -77,7 +92,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { } { - service := router.Group("/service") + service := protected.Group("/service") { serviceConfig := service.Group("/config") serviceConfig.GET("/timeout", handler.ServiceConfigTimeoutGET(ctx)) @@ -97,7 +112,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { } { - service := router.Group("/service") + service := protected.Group("/service") service.POST("/generic/invoke", handler.ServiceGenericInvoke(ctx)) service.GET("/method/detail", handler.GetServiceMethodDetail(ctx)) service.GET("/distribution", handler.GetServiceTabDistribution(ctx)) @@ -110,7 +125,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { } { - configuration := router.Group("/configurator") + configuration := protected.Group("/configurator") configuration.GET("/search", handler.ConfiguratorSearch(ctx)) configuration.GET("/:ruleName", handler.GetConfiguratorWithRuleName(ctx)) configuration.PUT("/:ruleName", handler.PutConfiguratorWithRuleName(ctx)) @@ -119,7 +134,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { } { - conditionRule := router.Group("/condition-rule") + conditionRule := protected.Group("/condition-rule") conditionRule.GET("/search", handler.ConditionRuleSearch(ctx)) conditionRule.GET("/:ruleName", handler.GetConditionRuleWithRuleName(ctx)) conditionRule.PUT("/:ruleName", handler.PutConditionRuleWithRuleName(ctx)) @@ -128,7 +143,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { } { - tagRule := router.Group("/tag-rule") + tagRule := protected.Group("/tag-rule") tagRule.GET("/search", handler.TagRuleSearch(ctx)) tagRule.GET("/:ruleName", handler.GetTagRuleWithRuleName(ctx)) tagRule.PUT("/:ruleName", handler.PutTagRuleWithRuleName(ctx)) @@ -136,9 +151,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { tagRule.DELETE("/:ruleName", handler.DeleteTagRuleWithRuleName(ctx)) } - router.GET("/prometheus", handler.GetPrometheus(ctx)) - router.GET("/search", handler.BannerGlobalSearch(ctx)) - router.GET("/overview", handler.ClusterOverview(ctx)) - router.GET("/metadata", handler.AdminMetadata(ctx)) - router.GET("/meshes", handler.ListMeshes(ctx)) + protected.GET("/prometheus", handler.GetPrometheus(ctx)) + protected.GET("/search", handler.BannerGlobalSearch(ctx)) + protected.GET("/overview", handler.ClusterOverview(ctx)) + protected.GET("/metadata", handler.AdminMetadata(ctx)) + protected.GET("/meshes", handler.ListMeshes(ctx)) + return nil } diff --git a/pkg/console/router/router_test.go b/pkg/console/router/router_test.go new file mode 100644 index 000000000..24993a4a9 --- /dev/null +++ b/pkg/console/router/router_test.go @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package router + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + + "github.com/apache/dubbo-admin/pkg/config/app" + consoleconfig "github.com/apache/dubbo-admin/pkg/config/console" + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" + consoleauth "github.com/apache/dubbo-admin/pkg/console/auth" + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + "github.com/apache/dubbo-admin/pkg/console/counter" + "github.com/apache/dubbo-admin/pkg/core/lock" + "github.com/apache/dubbo-admin/pkg/core/manager" +) + +func TestInitRouterSeparatesPublicAndProtectedAuthRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + r.Use(consoleauth.SessionMiddleware()) + if err := InitRouter(r, routerTestContext{}); err != nil { + t.Fatal(err) + } + + assertRouterStatus(t, r, http.MethodGet, "/api/v1/auth/providers", nil, nil, http.StatusOK) + assertRouterStatus(t, r, http.MethodPost, "/api/v1/auth/logout", nil, nil, http.StatusUnauthorized) + + loginBody := strings.NewReader("user=admin&password=secret") + login := assertRouterStatus(t, r, http.MethodPost, "/api/v1/auth/login", loginBody, nil, http.StatusOK) + if len(login.Result().Cookies()) == 0 { + t.Fatal("login did not set a session cookie") + } + assertRouterStatus(t, r, http.MethodGet, "/api/v1/auth/userinfo", nil, login.Result().Cookies()[0], http.StatusOK) +} + +type routerTestContext struct { + consolectx.Context +} + +func (routerTestContext) ResourceManager() manager.ResourceManager { return nil } +func (routerTestContext) CounterManager() counter.CounterManager { return nil } +func (routerTestContext) LockManager() lock.Lock { return nil } +func (routerTestContext) AppContext() context.Context { return context.Background() } +func (routerTestContext) Config() app.AdminConfig { + return app.AdminConfig{Console: &consoleconfig.Config{Auth: &configauth.Config{ + Methods: []string{configauth.MethodPassword}, + User: "admin", + Password: "secret", + ExpirationTime: 3600, + Providers: map[string]configauth.ProviderConfig{}, + }}} +} + +func assertRouterStatus( + t *testing.T, + router http.Handler, + method string, + path string, + body *strings.Reader, + cookie *http.Cookie, + want int, +) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body == nil { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if cookie != nil { + req.AddCookie(cookie) + } + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + if recorder.Code != want { + t.Fatalf("%s %s status = %d, want %d; body = %s", method, path, recorder.Code, want, recorder.Body.String()) + } + return recorder +} diff --git a/pkg/console/session_options_test.go b/pkg/console/session_options_test.go new file mode 100644 index 000000000..512f3dbd7 --- /dev/null +++ b/pkg/console/session_options_test.go @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package console + +import ( + "net/http" + "testing" + + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" +) + +func TestAdminSessionOptions(t *testing.T) { + cfg := &configauth.Config{ExpirationTime: 3600, SessionCookieSecure: true} + got := adminSessionOptions(cfg) + if !got.HttpOnly || !got.Secure || got.SameSite != http.SameSiteLaxMode || got.Path != "/" || got.MaxAge != 3600 { + t.Fatalf("adminSessionOptions() = %+v", got) + } +} diff --git a/ui-vue3/src/Login.test.ts b/ui-vue3/src/Login.test.ts new file mode 100644 index 000000000..a2f6dfe39 --- /dev/null +++ b/ui-vue3/src/Login.test.ts @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Login from './Login.vue' + +const { replace, loadAuthConfiguration, syncAuthenticatedPrincipal } = vi.hoisted(() => ({ + replace: vi.fn(), + loadAuthConfiguration: vi.fn(), + syncAuthenticatedPrincipal: vi.fn() +})) + +vi.mock('vue-router', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + useRouter: () => ({ replace }), + useRoute: () => ({ query: {} }) + } +}) + +vi.mock('@/auth/session', async (importOriginal) => { + const original = await importOriginal() + return { ...original, loadAuthConfiguration, syncAuthenticatedPrincipal } +}) + +vi.mock('@/api/service/globalSearch', () => ({ meshesSearch: vi.fn() })) + +describe('Login', () => { + const stubs = { + 'a-card': { template: '
' }, + 'a-row': { template: '
' }, + 'a-form': { template: '
' }, + 'a-form-item': { template: '' }, + 'a-input': { template: '' }, + 'a-button': { template: '' } + } + + beforeEach(() => { + vi.clearAllMocks() + syncAuthenticatedPrincipal.mockRejectedValue(new Error('no session')) + }) + + it('renders password and configured Provider login methods', async () => { + loadAuthConfiguration.mockResolvedValue({ + methods: ['password'], + providers: [{ id: 'github', displayName: 'GitHub' }] + }) + const wrapper = mount(Login, { + global: { + plugins: [createPinia()], + mocks: { $t: (key: string) => key }, + stubs + } + }) + await flushPromises() + expect(wrapper.find('.password-form').exists()).toBe(true) + expect(wrapper.text()).toContain('GitHub') + }) + + it('hides password form when the method is disabled', async () => { + loadAuthConfiguration.mockResolvedValue({ + methods: [], + providers: [{ id: 'sso', displayName: 'Company SSO' }] + }) + const wrapper = mount(Login, { + global: { + plugins: [createPinia()], + mocks: { $t: (key: string) => key }, + stubs + } + }) + await flushPromises() + expect(wrapper.find('.password-form').exists()).toBe(false) + expect(wrapper.text()).toContain('Company SSO') + }) + + it('treats null methods and providers as empty lists', async () => { + loadAuthConfiguration.mockResolvedValue({ + methods: null, + providers: null + }) + const wrapper = mount(Login, { + global: { + plugins: [createPinia()], + mocks: { $t: (key: string) => key }, + stubs + } + }) + await flushPromises() + expect(wrapper.find('.password-form').exists()).toBe(false) + expect(wrapper.find('.provider-list').exists()).toBe(false) + }) + + it('synchronizes OAuth callback identity through userinfo before redirecting', async () => { + loadAuthConfiguration.mockResolvedValue({ + methods: [], + providers: [{ id: 'sso', displayName: 'Company SSO' }] + }) + syncAuthenticatedPrincipal.mockResolvedValue({ username: 'alice' }) + mount(Login, { + global: { + plugins: [createPinia()], + mocks: { $t: (key: string) => key }, + stubs + } + }) + await flushPromises() + expect(syncAuthenticatedPrincipal).toHaveBeenCalledTimes(1) + expect(replace).toHaveBeenCalledWith('/') + }) +}) diff --git a/ui-vue3/src/Login.vue b/ui-vue3/src/Login.vue index dfe8f04e2..693bf66be 100644 --- a/ui-vue3/src/Login.vue +++ b/ui-vue3/src/Login.vue @@ -16,59 +16,82 @@ --> @@ -93,7 +127,6 @@ function loginHandle() { .background { background: url('assets/login.jpg') no-repeat center center fixed; background-size: cover; - //background-color: #f4f4f4; height: 100vh; width: 100vw; height: 100vh; display: flex; @@ -105,24 +138,27 @@ function loginHandle() { padding: 20px; border-radius: 12px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); - display: flex; - justify-content: center; min-width: 350px; width: 22vw; - //height: 30vh; .title { width: 100%; - display: flex; justify-content: center; font-size: 20px; font-weight: 500; margin-bottom: 20px; } - .login-btn { + .login-btn, + .provider-btn { width: 100%; } + + .provider-list { + display: grid; + gap: 12px; + margin-top: 12px; + } } } diff --git a/ui-vue3/src/api/service/login.ts b/ui-vue3/src/api/service/login.ts index 1c4782f54..d96f60434 100644 --- a/ui-vue3/src/api/service/login.ts +++ b/ui-vue3/src/api/service/login.ts @@ -17,6 +17,26 @@ import request from '@/base/http/request' +export interface AuthProvider { + id: string + displayName: string +} + +export interface AuthConfiguration { + methods: string[] + providers: AuthProvider[] +} + +export interface Principal { + subject: string + username: string + email: string + groups: string[] + roles: string[] + authType: string + provider: string +} + export const login = (data: any): Promise => { return request({ url: '/auth/login', @@ -31,3 +51,9 @@ export const logout = (): Promise => { method: 'post' }) } + +export const getAuthProviders = (): Promise<{ data: AuthConfiguration }> => + request({ url: '/auth/providers', method: 'get' }) + +export const getUserInfo = (): Promise<{ data: Principal }> => + request({ url: '/auth/userinfo', method: 'get' }) diff --git a/ui-vue3/src/auth/session.test.ts b/ui-vue3/src/auth/session.test.ts new file mode 100644 index 000000000..82da0e549 --- /dev/null +++ b/ui-vue3/src/auth/session.test.ts @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest' +import { providerLoginURL } from './session' + +describe('providerLoginURL', () => { + it('encodes the configured provider ID as one path segment', () => { + expect(providerLoginURL('company sso')).toBe('/api/v1/auth/providers/company%20sso/login') + }) +}) diff --git a/ui-vue3/src/auth/session.ts b/ui-vue3/src/auth/session.ts new file mode 100644 index 000000000..5f2a99601 --- /dev/null +++ b/ui-vue3/src/auth/session.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getAuthProviders, + getUserInfo, + type AuthConfiguration, + type Principal +} from '@/api/service/login' +import { updateAuthState } from '@/utils/AuthUtil' + +let configurationPromise: Promise | undefined + +export function providerLoginURL(providerID: string): string { + return `/api/v1/auth/providers/${encodeURIComponent(providerID)}/login` +} + +export async function loadAuthConfiguration(force = false): Promise { + if (!configurationPromise || force) { + configurationPromise = getAuthProviders() + .then(({ data }) => data) + .catch((error) => { + configurationPromise = undefined + throw error + }) + } + return configurationPromise +} + +export async function syncAuthenticatedPrincipal(): Promise { + const { data } = await getUserInfo() + updateAuthState(true, data.username) + return data +} diff --git a/ui-vue3/src/layout/header/layout_header.vue b/ui-vue3/src/layout/header/layout_header.vue index 98c32be43..f5ee4fd26 100644 --- a/ui-vue3/src/layout/header/layout_header.vue +++ b/ui-vue3/src/layout/header/layout_header.vue @@ -30,7 +30,10 @@ - {{ option.label }} @@ -91,7 +94,7 @@ - + @@ -120,7 +123,7 @@ import { UserOutlined } from '@ant-design/icons-vue' import { type ComponentInternalInstance, onMounted } from 'vue' -import { computed, getCurrentInstance, h, inject, nextTick, reactive, ref, watch } from 'vue' +import { computed, getCurrentInstance, h, inject, reactive, ref, watch } from 'vue' import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' import { changeLanguage, localeConfig } from '@/base/i18n' import { @@ -140,17 +143,16 @@ import { getAuthState, removeAuthState } from '@/utils/AuthUtil' import { logout } from '@/api/service/login' import { useMeshStore } from '@/stores/mesh' import { meshesSearch } from '@/api/service/globalSearch' +import { syncAuthenticatedPrincipal } from '@/auth/session' const { appContext: { config: { globalProperties } } -} = getCurrentInstance() +} = getCurrentInstance() as ComponentInternalInstance -let __null = PRIMARY_COLOR -let __null_r = PRIMARY_COLOR_R const collapsed = inject(PROVIDE_INJECT_KEY.COLLAPSED) -const i18nConfig = inject(PROVIDE_INJECT_KEY.LOCALE) +const i18nConfig = inject(PROVIDE_INJECT_KEY.LOCALE) as typeof localeConfig let locale = ref(localeConfig.locale) function changeTheme(val: string) { @@ -158,7 +160,7 @@ function changeTheme(val: string) { PRIMARY_COLOR.value = val } -function resetTheme(val: string) { +function resetTheme() { localStorage.removeItem(LOCAL_STORAGE_THEME) PRIMARY_COLOR.value = PRIMARY_COLOR_DEFAULT } @@ -178,11 +180,17 @@ const changeMesh = (value: any) => { } onMounted(async () => { + try { + const principal = await syncAuthenticatedPrincipal() + authState.value = { state: true, userinfo: { username: principal.username } } + } catch { + // The global Admin request interceptor handles an expired session. + } const { data } = await meshesSearch() meshes.value = data }) -const authState = getAuthState() +const authState = ref(getAuthState()) watch(locale, (value) => { changeLanguage(value) }) @@ -252,7 +260,7 @@ const onSearch = async () => { } // Listen for changes in searchType and trigger a search. -watch(searchType, async (newType) => { +watch(searchType, async () => { await onSearch() // When a change is detected, re-call the search function. }) diff --git a/ui-vue3/src/main.ts b/ui-vue3/src/main.ts index b9043a96f..4fdb3bbc1 100644 --- a/ui-vue3/src/main.ts +++ b/ui-vue3/src/main.ts @@ -28,8 +28,9 @@ import 'vue3-colorpicker/style.css' import 'nprogress/nprogress.css' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' -import { getAuthState, updateAuthState } from '@/utils/AuthUtil' +import { updateAuthState } from '@/utils/AuthUtil' import { createPinia } from 'pinia' +import { loadAuthConfiguration, syncAuthenticatedPrincipal } from '@/auth/session' async function bootstrap() { if (import.meta.env.VITE_MOCK_ENABLED === 'true') { @@ -45,16 +46,18 @@ async function bootstrap() { pinia.use(piniaPluginPersistedstate) - app.use(Antd).use(Vue3ColorPicker).use(pinia).use(i18n).use(router).mount('#app') - - router.beforeEach((to, from, next) => { - const authState = getAuthState() - if (authState?.state || to.path.startsWith('/login')) { - next() - } else { - next({ path: `/login?redirect=${to.path}` }) + router.beforeEach(async (to) => { + await loadAuthConfiguration() + if (to.path.startsWith('/login')) return true + try { + await syncAuthenticatedPrincipal() + return true + } catch { + return { path: '/login', query: { redirect: to.fullPath } } } }) + + app.use(Antd).use(Vue3ColorPicker).use(pinia).use(i18n).use(router).mount('#app') } bootstrap() diff --git a/ui-vue3/src/mocks/handlers/login.ts b/ui-vue3/src/mocks/handlers/login.ts index cc913a316..2d7bd7a33 100644 --- a/ui-vue3/src/mocks/handlers/login.ts +++ b/ui-vue3/src/mocks/handlers/login.ts @@ -19,6 +19,18 @@ import { http, type HttpHandler } from 'msw' import { success, base } from '../utils' export const loginHandlers: HttpHandler[] = [ + http.get(`${base}/auth/providers`, () => success({ methods: ['password'], providers: [] })), + http.get(`${base}/auth/userinfo`, () => + success({ + subject: 'local:admin', + username: 'admin', + email: '', + groups: [], + roles: [], + authType: 'password', + provider: 'local' + }) + ), http.post(`${base}/auth/login`, () => success(null)), http.post(`${base}/auth/logout`, () => success(null)) ]