Files
kubesphere/pkg/apiserver/authentication/oauth/oauth_client.go
2025-04-30 15:53:51 +08:00

241 lines
8.6 KiB
Go

/*
* Copyright 2024 the KubeSphere Authors.
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package oauth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"gopkg.in/yaml.v3"
v1 "k8s.io/api/core/v1"
errorsutil "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
"kubesphere.io/kubesphere/pkg/constants"
"kubesphere.io/kubesphere/pkg/utils/sliceutil"
)
const (
GrantMethodAuto = "auto"
GrantMethodPrompt = "prompt"
GrantMethodDeny = "deny"
ConfigTypeOAuthClient = "oauthclient"
SecretTypeOAuthClient = "config.kubesphere.io/" + ConfigTypeOAuthClient
SecretDataKey = "configuration.yaml"
)
var (
ErrorClientNotFound = errors.New("the OAuth client was not found")
ErrorRedirectURLNotAllowed = errors.New("redirect URL is not allowed")
ValidGrantMethods = []string{GrantMethodAuto, GrantMethodPrompt, GrantMethodDeny}
)
// Client represents an OAuth client configuration.
type Client struct {
// Name is the unique identifier for the OAuth client. It is used as the client_id parameter
// when making requests to <master>/oauth/authorize.
Name string `json:"name" yaml:"name"`
// Secret is the unique secret associated with the client for secure communication.
Secret string `json:"-" yaml:"secret"`
// Trusted indicates whether the client is considered a trusted client.
Trusted bool `json:"trusted" yaml:"trusted"`
// GrantMethod determines how grant requests for this client should be handled. If no method is provided,
// the cluster default grant handling method will be used. Valid grant handling methods are:
// - auto: Always approves grant requests, useful for trusted clients.
// - prompt: Prompts the end user for approval of grant requests, useful for third-party clients.
// - deny: Always denies grant requests, useful for black-listed clients.
GrantMethod string `json:"grantMethod" yaml:"grantMethod"`
// RespondWithChallenges indicates whether the client prefers authentication needed responses
// in the form of challenges instead of redirects.
RespondWithChallenges bool `json:"respondWithChallenges,omitempty" yaml:"respondWithChallenges,omitempty"`
// ScopeRestrictions describes which scopes this client can request. Each requested scope
// is checked against each restriction. If any restriction matches, then the scope is allowed.
// If no restriction matches, then the scope is denied.
ScopeRestrictions []string `json:"scopeRestrictions,omitempty" yaml:"scopeRestrictions,omitempty"`
// RedirectURIs is a list of valid redirection URIs associated with the client.
RedirectURIs []string `json:"redirectURIs,omitempty" yaml:"redirectURIs,omitempty"`
// AccessTokenMaxAge overrides the default maximum age for access tokens granted to this client.
// The default value is 7200 seconds, and the minimum allowed value is 600 seconds.
AccessTokenMaxAgeSeconds int64 `json:"accessTokenMaxAgeSeconds,omitempty" yaml:"accessTokenMaxAgeSeconds,omitempty"`
// AccessTokenInactivityTimeout overrides the default token inactivity timeout
// for tokens granted to this client.
AccessTokenInactivityTimeoutSeconds int64 `json:"accessTokenInactivityTimeoutSeconds,omitempty" yaml:"accessTokenInactivityTimeoutSeconds,omitempty"`
}
type ClientGetter interface {
GetOAuthClient(ctx context.Context, name string) (*Client, error)
ListOAuthClients(ctx context.Context) ([]*Client, error)
}
func NewOAuthClientGetter(reader client.Reader) ClientGetter {
return &oauthClientGetter{reader}
}
type oauthClientGetter struct {
client.Reader
}
func (o *oauthClientGetter) ListOAuthClients(ctx context.Context) ([]*Client, error) {
clients := make([]*Client, 0)
secrets := &v1.SecretList{}
if err := o.List(ctx, secrets, client.InNamespace(constants.KubeSphereNamespace),
client.MatchingLabels{constants.GenericConfigTypeLabel: ConfigTypeOAuthClient}); err != nil {
return nil, err
}
for _, secret := range secrets.Items {
if secret.Type != SecretTypeOAuthClient {
continue
}
if c, err := UnmarshalFrom(&secret); err != nil {
klog.Errorf("failed to unmarshal secret data: %s", err)
continue
} else {
clients = append(clients, c)
}
}
return clients, nil
}
// GetOAuthClient retrieves an OAuth client by name from the underlying storage.
// It returns the OAuth client if found; otherwise, returns an error.
func (o *oauthClientGetter) GetOAuthClient(ctx context.Context, name string) (*Client, error) {
clients, err := o.ListOAuthClients(ctx)
if err != nil {
klog.Errorf("failed to list OAuth clients: %v", err)
return nil, err
}
for _, c := range clients {
if c.Name == name {
return c, nil
}
}
return nil, ErrorClientNotFound
}
// ValidateClient validates the properties of the provided OAuth 2.0 client.
// It checks the client's grant method, access token inactivity timeout, and access
// token max age for validity. If any validation fails, it returns an aggregated error.
func ValidateClient(client Client) error {
var validationErrors []error
// Validate grant method.
if !sliceutil.HasString(ValidGrantMethods, client.GrantMethod) {
validationErrors = append(validationErrors, fmt.Errorf("invalid grant method: %s", client.GrantMethod))
}
// Validate access token inactivity timeout.
if client.AccessTokenInactivityTimeoutSeconds != 0 && client.AccessTokenInactivityTimeoutSeconds < 600 {
validationErrors = append(validationErrors, fmt.Errorf("invalid access token inactivity timeout: %d, the minimum value can only be 600", client.AccessTokenInactivityTimeoutSeconds))
}
// Validate access token max age.
if client.AccessTokenMaxAgeSeconds != 0 && client.AccessTokenMaxAgeSeconds < 600 {
validationErrors = append(validationErrors, fmt.Errorf("invalid access token max age: %d, the minimum value can only be 600", client.AccessTokenMaxAgeSeconds))
}
// Aggregate validation errors and return.
return errorsutil.NewAggregate(validationErrors)
}
// ResolveRedirectURL resolves the redirect URL for the OAuth 2.0 authorization process.
// It takes an expected URL as a parameter and returns the resolved URL if it's allowed.
// If the expected URL is not provided, it uses the first available RedirectURI from the client.
func (c *Client) ResolveRedirectURL(expectURL string) (*url.URL, error) {
// Check if RedirectURIs are specified for the client.
if len(c.RedirectURIs) == 0 {
return nil, ErrorRedirectURLNotAllowed
}
// Get the list of redirectable URIs for the client.
redirectAbleURIs := filterValidRedirectURIs(c.RedirectURIs)
// If the expected URL is not provided, use the first available RedirectURI.
if expectURL == "" {
if len(redirectAbleURIs) > 0 {
return url.Parse(redirectAbleURIs[0])
} else {
// No RedirectURIs available for the client.
return nil, ErrorRedirectURLNotAllowed
}
}
// Check if the provided expected URL is allowed.
if sliceutil.HasString(redirectAbleURIs, expectURL) {
return url.Parse(expectURL)
}
// The provided expected URL is not allowed.
return nil, ErrorRedirectURLNotAllowed
}
// IsValidScope checks whether the requested scope is valid for the client.
// It compares each individual scope in the requested scope string with the client's
// allowed scope restrictions. If all scopes are allowed, it returns true; otherwise, false.
func (c *Client) IsValidScope(requestedScope string) bool {
// Split the requested scope string into individual scopes.
scopes := strings.Split(requestedScope, " ")
// Check each individual scope against the client's scope restrictions.
for _, scope := range scopes {
if !sliceutil.HasString(c.ScopeRestrictions, scope) {
// Log a message indicating the disallowed scope.
klog.V(4).Infof("Invalid scope: %s is not allowed for client %s", scope, c.Name)
return false
}
}
// All scopes are valid.
return true
}
// filterValidRedirectURIs filters out invalid redirect URIs from the given slice.
// It returns a new slice containing only valid URIs.
func filterValidRedirectURIs(redirectURIs []string) []string {
validURIs := make([]string, 0)
for _, uri := range redirectURIs {
// Check if the URI is valid by attempting to parse it.
_, err := url.Parse(uri)
if err == nil {
// The URI is valid, add it to the list of valid URIs.
validURIs = append(validURIs, uri)
}
}
return validURIs
}
func UnmarshalFrom(secret *v1.Secret) (*Client, error) {
oc := &Client{}
if err := yaml.Unmarshal(secret.Data[SecretDataKey], oc); err != nil {
return nil, err
}
return oc, nil
}
func MarshalInto(client *Client, secret *v1.Secret) error {
data, err := yaml.Marshal(client)
if err != nil {
return err
}
secret.Data = map[string][]byte{SecretDataKey: data}
return nil
}