Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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.0.5
github.com/go-logr/logr v1.4.2
github.com/go-logr/zapr v1.3.0
github.com/golang/protobuf v1.5.4
Expand All @@ -45,6 +46,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
Expand Down Expand Up @@ -129,7 +131,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
Expand Down Expand Up @@ -247,7 +248,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
Expand Down
120 changes: 115 additions & 5 deletions pkg/config/console/auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,136 @@ package auth

import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"

"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 len(c.Methods) == 0 {
c.Methods = []string{MethodPassword}
}
Comment on lines +74 to +76
// 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 := validateHTTPURL(provider.Issuer); err != nil {
return fmt.Errorf("auth provider %q: invalid issuer: %w", id, err)
}
Comment on lines +131 to +134
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 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
}
107 changes: 107 additions & 0 deletions pkg/config/console/auth/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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 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"},
}

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 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/",
}
}
11 changes: 11 additions & 0 deletions pkg/config/console/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -72,6 +78,9 @@ func (c *Config) Validate() error {
if err := c.Auth.Validate(); err != nil {
return err
}
if c.GinMode == ReleaseMode && len(c.Auth.Providers) > 0 && c.Auth.SessionSecret == auth.DefaultSessionSecret {
return bizerror.New(bizerror.ConfigError, "auth sessionSecret must be explicitly configured when providers are enabled in release mode")
}
Comment on lines +81 to +83
return nil
}

Expand Down Expand Up @@ -122,9 +131,11 @@ func DefaultConsoleConfig() *Config {
GinMode: ReleaseMode,
Port: 8888,
Auth: &auth.Config{
Methods: []string{auth.MethodPassword},
User: "admin",
Password: "admin",
ExpirationTime: 3600,
SessionSecret: auth.DefaultSessionSecret,
},
}
}
58 changes: 58 additions & 0 deletions pkg/config/console/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 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)
}
}
Loading
Loading