mirror of https://github.com/grafana/grafana.git
474 lines
16 KiB
Go
474 lines
16 KiB
Go
package oasimpl
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v3"
|
|
"github.com/ory/fosite"
|
|
"github.com/ory/fosite/compose"
|
|
"github.com/ory/fosite/storage"
|
|
"github.com/ory/fosite/token/jwt"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/grafana/grafana/pkg/api/routing"
|
|
"github.com/grafana/grafana/pkg/bus"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
"github.com/grafana/grafana/pkg/infra/localcache"
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
"github.com/grafana/grafana/pkg/infra/slugify"
|
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/api"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/store"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
|
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
|
"github.com/grafana/grafana/pkg/services/signingkeys"
|
|
"github.com/grafana/grafana/pkg/services/team"
|
|
"github.com/grafana/grafana/pkg/services/user"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
)
|
|
|
|
const (
|
|
cacheExpirationTime = 5 * time.Minute
|
|
cacheCleanupInterval = 5 * time.Minute
|
|
)
|
|
|
|
type OAuth2ServiceImpl struct {
|
|
cache *localcache.CacheService
|
|
memstore *storage.MemoryStore
|
|
cfg *setting.Cfg
|
|
sqlstore oauthserver.Store
|
|
oauthProvider fosite.OAuth2Provider
|
|
logger log.Logger
|
|
accessControl ac.AccessControl
|
|
acService ac.Service
|
|
saService serviceaccounts.ExtSvcAccountsService
|
|
userService user.Service
|
|
teamService team.Service
|
|
publicKey any
|
|
}
|
|
|
|
func ProvideService(router routing.RouteRegister, bus bus.Bus, db db.DB, cfg *setting.Cfg,
|
|
extSvcAccSvc serviceaccounts.ExtSvcAccountsService, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service,
|
|
teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) {
|
|
if !fmgmt.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
|
|
return nil, nil
|
|
}
|
|
config := &fosite.Config{
|
|
AccessTokenLifespan: cfg.OAuth2ServerAccessTokenLifespan,
|
|
TokenURL: fmt.Sprintf("%voauth2/token", cfg.AppURL),
|
|
AccessTokenIssuer: cfg.AppURL,
|
|
IDTokenIssuer: cfg.AppURL,
|
|
ScopeStrategy: fosite.WildcardScopeStrategy,
|
|
}
|
|
|
|
s := &OAuth2ServiceImpl{
|
|
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
|
|
cfg: cfg,
|
|
accessControl: accessControl,
|
|
acService: acSvc,
|
|
memstore: storage.NewMemoryStore(),
|
|
sqlstore: store.NewStore(db),
|
|
logger: log.New("oauthserver"),
|
|
userService: userSvc,
|
|
saService: extSvcAccSvc,
|
|
teamService: teamSvc,
|
|
}
|
|
|
|
api := api.NewAPI(router, s)
|
|
api.RegisterAPIEndpoints()
|
|
|
|
bus.AddEventListener(s.handlePluginStateChanged)
|
|
|
|
s.oauthProvider = newProvider(config, s, keySvc)
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func newProvider(config *fosite.Config, storage any, signingKeyService signingkeys.Service) fosite.OAuth2Provider {
|
|
keyGetter := func(ctx context.Context) (any, error) {
|
|
_, key, err := signingKeyService.GetOrCreatePrivateKey(ctx, signingkeys.ServerPrivateKeyID, jose.ES256)
|
|
return key, err
|
|
}
|
|
return compose.Compose(
|
|
config,
|
|
storage,
|
|
&compose.CommonStrategy{
|
|
CoreStrategy: compose.NewOAuth2JWTStrategy(keyGetter, compose.NewOAuth2HMACStrategy(config), config),
|
|
Signer: &jwt.DefaultSigner{GetPrivateKey: keyGetter},
|
|
},
|
|
compose.OAuth2ClientCredentialsGrantFactory,
|
|
compose.RFC7523AssertionGrantFactory,
|
|
|
|
compose.OAuth2TokenIntrospectionFactory,
|
|
compose.OAuth2TokenRevocationFactory,
|
|
)
|
|
}
|
|
|
|
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
|
|
// SignedInUser from the associated service account.
|
|
// For performance reason, the service uses caching.
|
|
func (s *OAuth2ServiceImpl) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) {
|
|
entry, ok := s.cache.Get(id)
|
|
if ok {
|
|
client, ok := entry.(oauthserver.OAuthExternalService)
|
|
if ok {
|
|
s.logger.Debug("GetExternalService: cache hit", "id", id)
|
|
return &client, nil
|
|
}
|
|
}
|
|
|
|
client, err := s.sqlstore.GetExternalService(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.setClientUser(ctx, client); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.cache.Set(id, *client, cacheExpirationTime)
|
|
return client, nil
|
|
}
|
|
|
|
// setClientUser sets the SignedInUser and SelfPermissions fields of the client
|
|
func (s *OAuth2ServiceImpl) setClientUser(ctx context.Context, client *oauthserver.OAuthExternalService) error {
|
|
if client.ServiceAccountID == oauthserver.NoServiceAccountID {
|
|
s.logger.Debug("GetExternalService: service has no service account, hence no permission", "client_id", client.ClientID, "name", client.Name)
|
|
|
|
// Create a signed in user with no role and no permission
|
|
client.SignedInUser = &user.SignedInUser{
|
|
UserID: oauthserver.NoServiceAccountID,
|
|
OrgID: oauthserver.TmpOrgID,
|
|
Name: client.Name,
|
|
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
|
|
}
|
|
return nil
|
|
}
|
|
|
|
s.logger.Debug("GetExternalService: fetch permissions", "client_id", client.ClientID)
|
|
sa, err := s.saService.RetrieveExtSvcAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID)
|
|
if err != nil {
|
|
s.logger.Error("GetExternalService: error fetching service account", "id", client.ClientID, "error", err)
|
|
return err
|
|
}
|
|
client.SignedInUser = &user.SignedInUser{
|
|
UserID: sa.ID,
|
|
OrgID: oauthserver.TmpOrgID,
|
|
OrgRole: sa.Role,
|
|
Login: sa.Login,
|
|
Name: sa.Name,
|
|
Permissions: map[int64]map[string][]string{},
|
|
}
|
|
client.SelfPermissions, err = s.acService.GetUserPermissions(ctx, client.SignedInUser, ac.Options{})
|
|
if err != nil {
|
|
s.logger.Error("GetExternalService: error fetching permissions", "client_id", client.ClientID, "error", err)
|
|
return err
|
|
}
|
|
client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions)
|
|
return nil
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) RemoveExternalService(ctx context.Context, name string) error {
|
|
s.logger.Info("Remove external service", "service", name)
|
|
|
|
client, err := s.sqlstore.GetExternalServiceByName(ctx, name)
|
|
if err != nil {
|
|
if errors.Is(err, oauthserver.ErrClientNotFound) {
|
|
s.logger.Debug("No external service linked to this name", "name", name)
|
|
return nil
|
|
}
|
|
s.logger.Error("Error fetching external service", "name", name, "error", err.Error())
|
|
return err
|
|
}
|
|
|
|
// Since we will delete the service, clear cache entry
|
|
s.cache.Delete(client.ClientID)
|
|
|
|
// Delete the OAuth client info in store
|
|
if err := s.sqlstore.DeleteExternalService(ctx, client.ClientID); err != nil {
|
|
s.logger.Error("Error deleting external service", "name", name, "error", err.Error())
|
|
return err
|
|
}
|
|
s.logger.Debug("Deleted external service", "name", name, "client_id", client.ClientID)
|
|
|
|
// Remove the associated service account
|
|
return s.saService.RemoveExtSvcAccount(ctx, oauthserver.TmpOrgID, slugify.Slugify(name))
|
|
}
|
|
|
|
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
|
|
// it ensures that the associated service account has the correct permissions.
|
|
// Database consistency is not guaranteed, consider changing this in the future.
|
|
func (s *OAuth2ServiceImpl) SaveExternalService(ctx context.Context, registration *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
|
|
if registration == nil {
|
|
s.logger.Warn("RegisterExternalService called without registration")
|
|
return nil, nil
|
|
}
|
|
s.logger.Info("Registering external service", "external service name", registration.Name)
|
|
|
|
// Check if the client already exists in store
|
|
client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, registration.Name)
|
|
if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) {
|
|
s.logger.Error("Error fetching service", "external service", registration.Name, "error", errFetchExtSvc)
|
|
return nil, errFetchExtSvc
|
|
}
|
|
// Otherwise, create a new client
|
|
if client == nil {
|
|
s.logger.Debug("External service does not yet exist", "external service name", registration.Name)
|
|
client = &oauthserver.OAuthExternalService{
|
|
Name: registration.Name,
|
|
ServiceAccountID: oauthserver.NoServiceAccountID,
|
|
Audiences: s.cfg.AppURL,
|
|
}
|
|
}
|
|
|
|
// Parse registration form to compute required permissions for the client
|
|
client.SelfPermissions, client.ImpersonatePermissions = s.handleRegistrationPermissions(registration)
|
|
|
|
if registration.OAuthProviderCfg == nil {
|
|
return nil, errors.New("missing oauth provider configuration")
|
|
}
|
|
|
|
if registration.OAuthProviderCfg.RedirectURI != nil {
|
|
client.RedirectURI = *registration.OAuthProviderCfg.RedirectURI
|
|
}
|
|
|
|
var errGenCred error
|
|
client.ClientID, client.Secret, errGenCred = s.genCredentials()
|
|
if errGenCred != nil {
|
|
s.logger.Error("Error generating credentials", "client", client.LogID(), "error", errGenCred)
|
|
return nil, errGenCred
|
|
}
|
|
|
|
grantTypes := s.computeGrantTypes(registration.Self.Enabled, registration.Impersonation.Enabled)
|
|
client.GrantTypes = strings.Join(grantTypes, ",")
|
|
|
|
// Handle key options
|
|
s.logger.Debug("Handle key options")
|
|
keys, err := s.handleKeyOptions(ctx, registration.OAuthProviderCfg.Key)
|
|
if err != nil {
|
|
s.logger.Error("Error handling key options", "client", client.LogID(), "error", err)
|
|
return nil, err
|
|
}
|
|
if keys != nil {
|
|
client.PublicPem = []byte(keys.PublicPem)
|
|
}
|
|
dto := client.ToExternalService(keys)
|
|
|
|
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(client.Secret), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
s.logger.Error("Error hashing secret", "client", client.LogID(), "error", err)
|
|
return nil, err
|
|
}
|
|
client.Secret = string(hashedSecret)
|
|
|
|
s.logger.Debug("Save service account")
|
|
saID, errSaveServiceAccount := s.saService.ManageExtSvcAccount(ctx, &serviceaccounts.ManageExtSvcAccountCmd{
|
|
ExtSvcSlug: slugify.Slugify(client.Name),
|
|
Enabled: registration.Self.Enabled,
|
|
OrgID: oauthserver.TmpOrgID,
|
|
Permissions: client.SelfPermissions,
|
|
})
|
|
if errSaveServiceAccount != nil {
|
|
return nil, errSaveServiceAccount
|
|
}
|
|
client.ServiceAccountID = saID
|
|
|
|
err = s.sqlstore.SaveExternalService(ctx, client)
|
|
if err != nil {
|
|
s.logger.Error("Error saving external service", "client", client.LogID(), "error", err)
|
|
return nil, err
|
|
}
|
|
s.logger.Debug("Registered", "client", client.LogID())
|
|
return dto, nil
|
|
}
|
|
|
|
// randString generates a a cryptographically secure random string of n bytes
|
|
func (s *OAuth2ServiceImpl) randString(n int) (string, error) {
|
|
res := make([]byte, n)
|
|
if _, err := rand.Read(res); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(res), nil
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) genCredentials() (string, string, error) {
|
|
id, err := s.randString(20)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
// client_secret must be at least 32 bytes long
|
|
secret, err := s.randString(32)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return id, secret, err
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) computeGrantTypes(selfAccessEnabled, impersonationEnabled bool) []string {
|
|
grantTypes := []string{}
|
|
|
|
if selfAccessEnabled {
|
|
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
|
|
}
|
|
|
|
if impersonationEnabled {
|
|
grantTypes = append(grantTypes, string(fosite.GrantTypeJWTBearer))
|
|
}
|
|
|
|
return grantTypes
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) handleKeyOptions(ctx context.Context, keyOption *extsvcauth.KeyOption) (*extsvcauth.KeyResult, error) {
|
|
if keyOption == nil {
|
|
return nil, fmt.Errorf("keyOption is nil")
|
|
}
|
|
|
|
var publicPem, privatePem string
|
|
|
|
if keyOption.Generate {
|
|
switch s.cfg.OAuth2ServerGeneratedKeyTypeForClient {
|
|
case "RSA":
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicPem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PUBLIC KEY",
|
|
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
|
|
}))
|
|
privatePem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
|
}))
|
|
s.logger.Debug("RSA key has been generated")
|
|
default: // default to ECDSA
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicDer, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
privateDer, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
publicPem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "PUBLIC KEY",
|
|
Bytes: publicDer,
|
|
}))
|
|
privatePem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: privateDer,
|
|
}))
|
|
s.logger.Debug("ECDSA key has been generated")
|
|
}
|
|
|
|
return &extsvcauth.KeyResult{
|
|
PrivatePem: privatePem,
|
|
PublicPem: publicPem,
|
|
Generated: true,
|
|
}, nil
|
|
}
|
|
|
|
// TODO MVP allow specifying a URL to get the public key
|
|
// if registration.Key.URL != "" {
|
|
// return &oauthserver.KeyResult{
|
|
// URL: registration.Key.URL,
|
|
// }, nil
|
|
// }
|
|
|
|
if keyOption.PublicPEM != "" {
|
|
pemEncoded, err := base64.StdEncoding.DecodeString(keyOption.PublicPEM)
|
|
if err != nil {
|
|
s.logger.Error("Cannot decode base64 encoded PEM string", "error", err)
|
|
}
|
|
_, err = utils.ParsePublicKeyPem(pemEncoded)
|
|
if err != nil {
|
|
s.logger.Error("Cannot parse PEM encoded string", "error", err)
|
|
return nil, err
|
|
}
|
|
return &extsvcauth.KeyResult{
|
|
PublicPem: string(pemEncoded),
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("at least one key option must be specified")
|
|
}
|
|
|
|
// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default
|
|
// permissions when impersonation is requested
|
|
func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *extsvcauth.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) {
|
|
selfPermissions := registration.Self.Permissions
|
|
impersonatePermissions := []ac.Permission{}
|
|
|
|
if len(registration.Impersonation.Permissions) > 0 {
|
|
requiredForToken := []ac.Permission{
|
|
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
|
|
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
|
|
}
|
|
if registration.Impersonation.Groups {
|
|
requiredForToken = append(requiredForToken, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
|
|
}
|
|
impersonatePermissions = append(requiredForToken, registration.Impersonation.Permissions...)
|
|
selfPermissions = append(selfPermissions, ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll})
|
|
}
|
|
return selfPermissions, impersonatePermissions
|
|
}
|
|
|
|
// handlePluginStateChanged reset the client authorized grant_types according to the plugin state
|
|
func (s *OAuth2ServiceImpl) handlePluginStateChanged(ctx context.Context, event *pluginsettings.PluginStateChangedEvent) error {
|
|
s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
|
|
|
|
// Retrieve client associated to the plugin
|
|
client, err := s.sqlstore.GetExternalServiceByName(ctx, event.PluginId)
|
|
if err != nil {
|
|
if errors.Is(err, oauthserver.ErrClientNotFound) {
|
|
s.logger.Debug("No external service linked to this plugin", "pluginId", event.PluginId)
|
|
return nil
|
|
}
|
|
s.logger.Error("Error fetching service", "pluginId", event.PluginId, "error", err.Error())
|
|
return err
|
|
}
|
|
|
|
// Since we will change the grants, clear cache entry
|
|
s.cache.Delete(client.ClientID)
|
|
|
|
if !event.Enabled {
|
|
// Plugin is disabled => remove all grant_types
|
|
return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, "")
|
|
}
|
|
|
|
if err := s.setClientUser(ctx, client); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The plugin has self permissions (not only impersonate)
|
|
canOnlyImpersonate := len(client.SelfPermissions) == 1 && (client.SelfPermissions[0].Action == ac.ActionUsersImpersonate)
|
|
selfEnabled := len(client.SelfPermissions) > 0 && !canOnlyImpersonate
|
|
// The plugin declared impersonate permissions
|
|
impersonateEnabled := len(client.ImpersonatePermissions) > 0
|
|
|
|
grantTypes := s.computeGrantTypes(selfEnabled, impersonateEnabled)
|
|
|
|
return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, strings.Join(grantTypes, ","))
|
|
}
|