Copilot commented on code in PR #1542:
URL: https://github.com/apache/dubbo-admin/pull/1542#discussion_r3921739305
##########
pkg/config/console/auth/config.go:
##########
@@ -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}
+ }
+ // 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)
+ }
Review Comment:
OIDC configuration currently accepts a plaintext HTTP issuer, and discovery
only checks that token/JWKS/UserInfo endpoints are non-empty. This can send the
authorization code, client secret, access token, or signing keys over
unauthenticated HTTP. Require HTTPS for the issuer and discovered endpoints
(with any loopback development exception made explicit).
##########
pkg/console/auth/oidc.go:
##########
@@ -0,0 +1,229 @@
+/*
+ * 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/http"
+ "strings"
+ "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"
+ "golang.org/x/oauth2"
+)
+
+type oidcDiscovery struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ JWKSURI string `json:"jwks_uri"`
+ UserInfoEndpoint string `json:"userinfo_endpoint"`
+}
+
+type oidcProfile struct {
+ Nonce string `json:"nonce"`
+ PreferredUsername string `json:"preferred_username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Groups []string `json:"groups"`
+ Roles []string `json:"roles"`
+}
+
+type oidcProvider struct {
+ id string
+ displayName string
+ issuer string
+ clientID string
+ postLoginRedirectURL string
+ discovery oidcDiscovery
+ oauth oauth2.Config
+ httpClient *http.Client
+}
+
+func NewOIDCProvider(ctx context.Context, id string, cfg
configauth.ProviderConfig, client *http.Client) (Provider, error) {
+ if client == nil {
+ client = http.DefaultClient
+ }
+ discoveryURL := strings.TrimRight(cfg.Issuer, "/") +
"/.well-known/openid-configuration"
+ var discovery oidcDiscovery
+ if err := getOIDCJSON(ctx, client, discoveryURL, "", &discovery); err
!= nil {
+ return nil, fmt.Errorf("discover OIDC provider %q: %w", id, err)
+ }
+ if discovery.Issuer != cfg.Issuer {
+ return nil, fmt.Errorf("OIDC provider %q discovery issuer %q
does not match configured issuer %q", id, discovery.Issuer, cfg.Issuer)
+ }
+ if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint ==
"" || discovery.JWKSURI == "" {
+ return nil, fmt.Errorf("OIDC provider %q discovery is missing
required endpoints", id)
+ }
+ provider := &oidcProvider{
+ id: id, displayName: cfg.DisplayName, issuer: cfg.Issuer,
clientID: cfg.ClientID,
+ postLoginRedirectURL: cfg.PostLoginRedirectURL, discovery:
discovery, httpClient: client,
+ }
+ provider.oauth = oauth2.Config{
+ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
+ Scopes: append([]string(nil), cfg.Scopes...),
+ Endpoint: oauth2.Endpoint{AuthURL:
discovery.AuthorizationEndpoint, TokenURL: discovery.TokenEndpoint},
+ }
+ 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")
+ }
+ claims, profile, err := p.verifyIDToken(ctx, rawIDToken)
+ if err != nil {
+ return Principal{}, err
+ }
+ if subtle.ConstantTimeCompare([]byte(profile.Nonce), []byte(nonce)) !=
1 {
+ return Principal{}, errors.New("OIDC ID Token nonce does not
match OAuth transaction")
+ }
+ if claims.Subject == "" {
+ return Principal{}, errors.New("OIDC ID Token subject is
missing")
+ }
+ if oidcUsername(profile, claims.Subject) == "" || profile.Email == "" {
+ if p.discovery.UserInfoEndpoint != "" {
+ var userInfo struct {
+ Subject string `json:"sub"`
+ oidcProfile
+ }
+ if err := getOIDCJSON(ctx, p.httpClient,
p.discovery.UserInfoEndpoint, token.AccessToken, &userInfo); err != nil {
+ return Principal{}, fmt.Errorf("read OIDC
UserInfo: %w", err)
+ }
+ if userInfo.Subject != claims.Subject {
+ return Principal{}, errors.New("OIDC UserInfo
subject does not match ID Token subject")
+ }
+ mergeOIDCProfile(&profile, userInfo.oidcProfile)
+ }
+ }
+ return Principal{
+ Subject: p.id + ":" + claims.Subject, Username:
oidcUsername(profile, claims.Subject), Email: profile.Email,
+ Groups: nonNilStrings(profile.Groups), Roles:
nonNilStrings(profile.Roles), AuthType: "oidc", Provider: p.id,
+ }, nil
+}
+
+func (p *oidcProvider) verifyIDToken(ctx context.Context, raw string)
(josejwt.Claims, oidcProfile, error) {
+ token, err := josejwt.ParseSigned(raw,
[]jose.SignatureAlgorithm{jose.RS256})
+ if err != nil {
+ return josejwt.Claims{}, oidcProfile{}, fmt.Errorf("parse OIDC
ID Token: %w", err)
+ }
+ if len(token.Headers) != 1 || token.Headers[0].KeyID == "" {
+ return josejwt.Claims{}, oidcProfile{}, errors.New("OIDC ID
Token kid is missing")
+ }
+ var keySet jose.JSONWebKeySet
+ if err := getOIDCJSON(ctx, p.httpClient, p.discovery.JWKSURI, "",
&keySet); err != nil {
+ return josejwt.Claims{}, oidcProfile{}, fmt.Errorf("read OIDC
JWKS: %w", err)
+ }
+ keys := keySet.Key(token.Headers[0].KeyID)
+ if len(keys) != 1 || keys[0].Algorithm != string(jose.RS256) {
+ return josejwt.Claims{}, oidcProfile{}, errors.New("OIDC ID
Token signing key is unknown or not RS256")
+ }
+ var claims josejwt.Claims
+ var profile oidcProfile
+ if err := token.Claims(keys[0].Key, &claims, &profile); err != nil {
+ return josejwt.Claims{}, oidcProfile{}, fmt.Errorf("verify OIDC
ID Token signature: %w", err)
+ }
+ if claims.Expiry == nil {
+ return josejwt.Claims{}, oidcProfile{}, errors.New("OIDC ID
Token expiration is missing")
+ }
+ if err := claims.ValidateWithLeeway(josejwt.Expected{
+ Issuer: p.issuer, AnyAudience: josejwt.Audience{p.clientID},
Time: time.Now(),
+ }, 0); err != nil {
+ return josejwt.Claims{}, oidcProfile{}, fmt.Errorf("validate
OIDC ID Token issuer, audience, or expiration: %w", err)
Review Comment:
`AnyAudience` accepts a multi-audience ID Token as long as this client ID
appears anywhere, but the implementation never validates the OIDC `azp` claim.
A token whose authorized party is another client can therefore be accepted.
Decode `azp`, require it for multi-audience tokens, and reject any present
value that differs from `p.clientID`.
##########
pkg/config/console/config.go:
##########
@@ -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")
+ }
Review Comment:
This production check rejects only the literal legacy default, so values
such as `sessionSecret: x` are accepted. The secret signs the cookie containing
the authenticated `Principal`; a low-entropy key can be brute-forced and then
used to forge an admin session. Require an adequate minimum key length for
provider-enabled release deployments.
##########
pkg/console/auth/github.go:
##########
@@ -0,0 +1,146 @@
+/*
+ * 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"
+ "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
+}
+
+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,
+ }
+}
+
+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 == "" {
+ var emails []githubEmail
+ if err := getGitHubJSON(ctx, client,
p.apiBaseURL+"/user/emails", &emails); err != nil {
+ return Principal{}, fmt.Errorf("decode GitHub emails:
%w", err)
+ }
Review Comment:
Custom GitHub scopes are allowed, but `/user/emails` requires the
`user:email` scope. For users with no public email, a valid configuration such
as `scopes: [read:user]` therefore reaches this call, receives an authorization
error, and rejects the entire login even though `Principal.Email` may be empty.
Either require `user:email` during validation or skip this fallback when the
scope was not granted.
##########
pkg/console/auth/oidc.go:
##########
@@ -0,0 +1,229 @@
+/*
+ * 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/http"
+ "strings"
+ "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"
+ "golang.org/x/oauth2"
+)
+
+type oidcDiscovery struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ JWKSURI string `json:"jwks_uri"`
+ UserInfoEndpoint string `json:"userinfo_endpoint"`
+}
+
+type oidcProfile struct {
+ Nonce string `json:"nonce"`
+ PreferredUsername string `json:"preferred_username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Groups []string `json:"groups"`
+ Roles []string `json:"roles"`
+}
+
+type oidcProvider struct {
+ id string
+ displayName string
+ issuer string
+ clientID string
+ postLoginRedirectURL string
+ discovery oidcDiscovery
+ oauth oauth2.Config
+ httpClient *http.Client
+}
+
+func NewOIDCProvider(ctx context.Context, id string, cfg
configauth.ProviderConfig, client *http.Client) (Provider, error) {
+ if client == nil {
+ client = http.DefaultClient
+ }
Review Comment:
`http.DefaultClient` has no overall timeout. OIDC discovery runs
synchronously from the core Console component's `Start`, so an issuer that
accepts a connection but never responds can hang Console startup indefinitely
instead of returning a startup error. Use a bounded client for discovery and
subsequent provider requests.
##########
pkg/console/auth/oidc.go:
##########
@@ -0,0 +1,229 @@
+/*
+ * 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/http"
+ "strings"
+ "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"
+ "golang.org/x/oauth2"
+)
+
+type oidcDiscovery struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ JWKSURI string `json:"jwks_uri"`
+ UserInfoEndpoint string `json:"userinfo_endpoint"`
+}
+
+type oidcProfile struct {
+ Nonce string `json:"nonce"`
+ PreferredUsername string `json:"preferred_username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Groups []string `json:"groups"`
+ Roles []string `json:"roles"`
+}
+
+type oidcProvider struct {
+ id string
+ displayName string
+ issuer string
+ clientID string
+ postLoginRedirectURL string
+ discovery oidcDiscovery
+ oauth oauth2.Config
+ httpClient *http.Client
+}
+
+func NewOIDCProvider(ctx context.Context, id string, cfg
configauth.ProviderConfig, client *http.Client) (Provider, error) {
+ if client == nil {
+ client = http.DefaultClient
+ }
+ discoveryURL := strings.TrimRight(cfg.Issuer, "/") +
"/.well-known/openid-configuration"
+ var discovery oidcDiscovery
+ if err := getOIDCJSON(ctx, client, discoveryURL, "", &discovery); err
!= nil {
+ return nil, fmt.Errorf("discover OIDC provider %q: %w", id, err)
+ }
+ if discovery.Issuer != cfg.Issuer {
+ return nil, fmt.Errorf("OIDC provider %q discovery issuer %q
does not match configured issuer %q", id, discovery.Issuer, cfg.Issuer)
+ }
+ if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint ==
"" || discovery.JWKSURI == "" {
+ return nil, fmt.Errorf("OIDC provider %q discovery is missing
required endpoints", id)
+ }
+ provider := &oidcProvider{
+ id: id, displayName: cfg.DisplayName, issuer: cfg.Issuer,
clientID: cfg.ClientID,
+ postLoginRedirectURL: cfg.PostLoginRedirectURL, discovery:
discovery, httpClient: client,
+ }
+ provider.oauth = oauth2.Config{
+ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
+ Scopes: append([]string(nil), cfg.Scopes...),
+ Endpoint: oauth2.Endpoint{AuthURL:
discovery.AuthorizationEndpoint, TokenURL: discovery.TokenEndpoint},
+ }
+ 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")
+ }
+ claims, profile, err := p.verifyIDToken(ctx, rawIDToken)
+ if err != nil {
+ return Principal{}, err
+ }
+ if subtle.ConstantTimeCompare([]byte(profile.Nonce), []byte(nonce)) !=
1 {
+ return Principal{}, errors.New("OIDC ID Token nonce does not
match OAuth transaction")
+ }
+ if claims.Subject == "" {
+ return Principal{}, errors.New("OIDC ID Token subject is
missing")
+ }
+ if oidcUsername(profile, claims.Subject) == "" || profile.Email == "" {
+ if p.discovery.UserInfoEndpoint != "" {
+ var userInfo struct {
+ Subject string `json:"sub"`
+ oidcProfile
+ }
+ if err := getOIDCJSON(ctx, p.httpClient,
p.discovery.UserInfoEndpoint, token.AccessToken, &userInfo); err != nil {
+ return Principal{}, fmt.Errorf("read OIDC
UserInfo: %w", err)
+ }
+ if userInfo.Subject != claims.Subject {
+ return Principal{}, errors.New("OIDC UserInfo
subject does not match ID Token subject")
+ }
+ mergeOIDCProfile(&profile, userInfo.oidcProfile)
+ }
+ }
+ return Principal{
+ Subject: p.id + ":" + claims.Subject, Username:
oidcUsername(profile, claims.Subject), Email: profile.Email,
+ Groups: nonNilStrings(profile.Groups), Roles:
nonNilStrings(profile.Roles), AuthType: "oidc", Provider: p.id,
+ }, nil
+}
+
+func (p *oidcProvider) verifyIDToken(ctx context.Context, raw string)
(josejwt.Claims, oidcProfile, error) {
+ token, err := josejwt.ParseSigned(raw,
[]jose.SignatureAlgorithm{jose.RS256})
+ if err != nil {
+ return josejwt.Claims{}, oidcProfile{}, fmt.Errorf("parse OIDC
ID Token: %w", err)
+ }
+ if len(token.Headers) != 1 || token.Headers[0].KeyID == "" {
+ return josejwt.Claims{}, oidcProfile{}, errors.New("OIDC ID
Token kid is missing")
+ }
+ var keySet jose.JSONWebKeySet
+ if err := getOIDCJSON(ctx, p.httpClient, p.discovery.JWKSURI, "",
&keySet); err != nil {
+ return josejwt.Claims{}, oidcProfile{}, fmt.Errorf("read OIDC
JWKS: %w", err)
+ }
+ keys := keySet.Key(token.Headers[0].KeyID)
+ if len(keys) != 1 || keys[0].Algorithm != string(jose.RS256) {
+ return josejwt.Claims{}, oidcProfile{}, errors.New("OIDC ID
Token signing key is unknown or not RS256")
+ }
Review Comment:
A JWK's `alg` member is optional, but this rejects an otherwise valid RS256
key whenever the provider omits it. Since `ParseSigned` already restricts the
token header to RS256, accept an empty JWK algorithm while still rejecting a
conflicting declared algorithm.
##########
pkg/console/handler/auth.go:
##########
@@ -18,53 +18,159 @@
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(nil), 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
+ }
+ // Persist consumption before contacting the Provider so failures
cannot be replayed.
+ if err := session.Save(); err != nil {
+ writeSessionError(c, err)
+ return
+ }
Review Comment:
With `cookie.NewStore`, deleting and saving the transaction only sends a
replacement cookie; it does not revoke the original client-held cookie. A
concurrent request or a client replaying the login response's original cookie
reconstructs the transaction and reaches `Complete` again, so the transaction
is not server-side single-use (the test only retries with the updated cookie).
Store transactions in a server-side cache keyed by state and consume them
atomically, then test replay with the original cookie.
##########
pkg/config/console/auth/config.go:
##########
@@ -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}
+ }
Review Comment:
An explicitly empty `methods` list is indistinguishable here from an omitted
list, so validation re-enables password login. Because `password` is the only
accepted method, provider-only deployments cannot actually disable the password
endpoint as advertised. Default only a nil (omitted) slice; preserve a non-nil
empty slice.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]