mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
Authn: Add client for api keys (#60339)
* AuthN: Add functionallity to test if auth client should be used * AuthN: Add bolierplate client for api keys and register it * AuthN: Add tests for api key client * Inject service * AuthN: Update client names * ContextHandler: Set authn service * AuthN: Implement authentication for api key client * ContextHandler: Use authn service for api keys if flag is enabled * AuthN: refactor authentication method to return additional value to indicate if client could perform authentication * update prefixes * Add namespaced id to identity * AuthN: Expand the Identity struct to include required fields from signed in user * Add error for disabled service account * Add function to write error response based on errutil.Error * Add error to log * Return errors based on errutil.Error * pass error * update log message * Fix namespaced ids * Add tests * Lint
This commit is contained in:
parent
cc4d18f626
commit
2e53a58bc3
@ -3,43 +3,115 @@ package authn
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ClientAnonymous = "auth.anonymous"
|
ClientAPIKey = "auth.client.api-key" // #nosec G101
|
||||||
|
ClientAnonymous = "auth.client.anonymous"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
Authenticate(ctx context.Context, client string, r *Request) (*Identity, error)
|
// Authenticate is used to authenticate using a specific client
|
||||||
|
Authenticate(ctx context.Context, client string, r *Request) (*Identity, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
|
// Authenticate performs the authentication for the request
|
||||||
Authenticate(ctx context.Context, r *Request) (*Identity, error)
|
Authenticate(ctx context.Context, r *Request) (*Identity, error)
|
||||||
|
// Test should return true if client can be used to authenticate request
|
||||||
|
Test(ctx context.Context, r *Request) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
HTTPRequest *http.Request
|
HTTPRequest *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
APIKeyIDPrefix = "api-key:"
|
||||||
|
ServiceAccountIDPrefix = "service-account:"
|
||||||
|
)
|
||||||
|
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
OrgID int64
|
ID string
|
||||||
OrgName string
|
OrgID int64
|
||||||
IsAnonymous bool
|
OrgCount int
|
||||||
OrgRoles map[int64]org.RoleType
|
OrgName string
|
||||||
|
OrgRoles map[int64]org.RoleType
|
||||||
|
Login string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
AuthID string
|
||||||
|
AuthModule string
|
||||||
|
IsGrafanaAdmin bool
|
||||||
|
IsDisabled bool
|
||||||
|
HelpFlags1 user.HelpFlags1
|
||||||
|
LastSeenAt time.Time
|
||||||
|
Teams []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Identity) Role() org.RoleType {
|
func (i *Identity) Role() org.RoleType {
|
||||||
return i.OrgRoles[i.OrgID]
|
return i.OrgRoles[i.OrgID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAnonymous will return true if no ID is set on the identity
|
||||||
|
func (i *Identity) IsAnonymous() bool {
|
||||||
|
return i.ID == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedInUser is used to translate Identity into SignedInUser struct
|
||||||
func (i *Identity) SignedInUser() *user.SignedInUser {
|
func (i *Identity) SignedInUser() *user.SignedInUser {
|
||||||
return &user.SignedInUser{
|
u := &user.SignedInUser{
|
||||||
OrgID: i.OrgID,
|
UserID: 0,
|
||||||
OrgName: i.OrgName,
|
OrgID: i.OrgID,
|
||||||
OrgRole: i.Role(),
|
OrgName: i.OrgName,
|
||||||
IsAnonymous: i.IsAnonymous,
|
OrgRole: i.Role(),
|
||||||
|
ExternalAuthModule: i.AuthModule,
|
||||||
|
ExternalAuthID: i.AuthID,
|
||||||
|
Login: i.Login,
|
||||||
|
Name: i.Name,
|
||||||
|
Email: i.Email,
|
||||||
|
OrgCount: i.OrgCount,
|
||||||
|
IsGrafanaAdmin: i.IsGrafanaAdmin,
|
||||||
|
IsAnonymous: i.IsAnonymous(),
|
||||||
|
IsDisabled: i.IsDisabled,
|
||||||
|
HelpFlags1: i.HelpFlags1,
|
||||||
|
LastSeenAt: i.LastSeenAt,
|
||||||
|
Teams: i.Teams,
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, we need to set different fields of the signed-in user based on the identity "type"
|
||||||
|
if strings.HasPrefix(i.ID, APIKeyIDPrefix) {
|
||||||
|
id, _ := strconv.ParseInt(strings.TrimPrefix(i.ID, APIKeyIDPrefix), 10, 64)
|
||||||
|
u.ApiKeyID = id
|
||||||
|
} else if strings.HasPrefix(i.ID, ServiceAccountIDPrefix) {
|
||||||
|
id, _ := strconv.ParseInt(strings.TrimPrefix(i.ID, ServiceAccountIDPrefix), 10, 64)
|
||||||
|
u.UserID = id
|
||||||
|
u.IsServiceAccount = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func IdentityFromSignedInUser(id string, usr *user.SignedInUser) *Identity {
|
||||||
|
return &Identity{
|
||||||
|
ID: id,
|
||||||
|
OrgID: usr.OrgID,
|
||||||
|
OrgName: usr.OrgName,
|
||||||
|
OrgRoles: map[int64]org.RoleType{usr.OrgID: usr.OrgRole},
|
||||||
|
Login: usr.Login,
|
||||||
|
Name: usr.Name,
|
||||||
|
Email: usr.Email,
|
||||||
|
OrgCount: usr.OrgCount,
|
||||||
|
IsGrafanaAdmin: usr.IsGrafanaAdmin,
|
||||||
|
IsDisabled: usr.IsDisabled,
|
||||||
|
HelpFlags1: usr.HelpFlags1,
|
||||||
|
LastSeenAt: usr.LastSeenAt,
|
||||||
|
Teams: usr.Teams,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,23 +5,28 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
|
"github.com/grafana/grafana/pkg/services/apikey"
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/services/authn/clients"
|
"github.com/grafana/grafana/pkg/services/authn/clients"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ authn.Service = new(Service)
|
var _ authn.Service = new(Service)
|
||||||
|
|
||||||
func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service) *Service {
|
func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service, apikeyService apikey.Service, userService user.Service) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
log: log.New("authn.service"),
|
log: log.New("authn.service"),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
clients: make(map[string]authn.Client),
|
clients: make(map[string]authn.Client),
|
||||||
tracer: tracer,
|
tracer: tracer,
|
||||||
|
userService: userService,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.clients[authn.ClientAPIKey] = clients.ProvideAPIKey(apikeyService, userService)
|
||||||
|
|
||||||
if s.cfg.AnonymousEnabled {
|
if s.cfg.AnonymousEnabled {
|
||||||
s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService)
|
s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService)
|
||||||
}
|
}
|
||||||
@ -34,20 +39,35 @@ type Service struct {
|
|||||||
cfg *setting.Cfg
|
cfg *setting.Cfg
|
||||||
clients map[string]authn.Client
|
clients map[string]authn.Client
|
||||||
|
|
||||||
tracer tracing.Tracer
|
tracer tracing.Tracer
|
||||||
|
userService user.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Authenticate(ctx context.Context, clientName string, r *authn.Request) (*authn.Identity, error) {
|
func (s *Service) Authenticate(ctx context.Context, client string, r *authn.Request) (*authn.Identity, bool, error) {
|
||||||
ctx, span := s.tracer.Start(ctx, "authn.Authenticate")
|
ctx, span := s.tracer.Start(ctx, "authn.Authenticate")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
span.SetAttributes("authn.client", clientName, attribute.Key("authn.client").String(clientName))
|
span.SetAttributes("authn.client", client, attribute.Key("authn.client").String(client))
|
||||||
|
logger := s.log.FromContext(ctx)
|
||||||
|
|
||||||
client, ok := s.clients[clientName]
|
c, ok := s.clients[client]
|
||||||
if !ok {
|
if !ok {
|
||||||
s.log.FromContext(ctx).Warn("auth client not found", "client", clientName)
|
logger.Debug("auth client not found", "client", client)
|
||||||
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client is not configured"}})
|
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client is not configured"}})
|
||||||
return nil, authn.ErrClientNotFound
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.Test(ctx, r) {
|
||||||
|
logger.Debug("auth client cannot handle request", "client", client)
|
||||||
|
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client cannot handle request"}})
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := c.Authenticate(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("auth client could not authenticate request", "client", client, "error", err)
|
||||||
|
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client could not authenticate request"}})
|
||||||
|
return nil, true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: We want to perform common authentication operations here.
|
// FIXME: We want to perform common authentication operations here.
|
||||||
@ -58,5 +78,6 @@ func (s *Service) Authenticate(ctx context.Context, clientName string, r *authn.
|
|||||||
// login handler, but if we want to perform basic auth during a request (called from contexthandler) we don't
|
// login handler, but if we want to perform basic auth during a request (called from contexthandler) we don't
|
||||||
// want a session to be created.
|
// want a session to be created.
|
||||||
|
|
||||||
return client.Authenticate(ctx, r)
|
logger.Debug("auth client successfully authenticated request", "client", client, "identity", identity)
|
||||||
|
return identity, true, nil
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package authnimpl
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -17,6 +18,7 @@ func TestService_Authenticate(t *testing.T) {
|
|||||||
type TestCase struct {
|
type TestCase struct {
|
||||||
desc string
|
desc string
|
||||||
clientName string
|
clientName string
|
||||||
|
expectedOK bool
|
||||||
expectedErr error
|
expectedErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,22 +26,35 @@ func TestService_Authenticate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
desc: "should succeed with authentication for configured client",
|
desc: "should succeed with authentication for configured client",
|
||||||
clientName: "fake",
|
clientName: "fake",
|
||||||
|
expectedOK: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "should fail when client is not configured",
|
desc: "should return false when client is not configured",
|
||||||
clientName: "gitlab",
|
clientName: "gitlab",
|
||||||
expectedErr: authn.ErrClientNotFound,
|
expectedOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should return true and error when client could be used but failed to authenticate",
|
||||||
|
clientName: "fake",
|
||||||
|
expectedOK: true,
|
||||||
|
expectedErr: errors.New("some error"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.desc, func(t *testing.T) {
|
t.Run(tt.desc, func(t *testing.T) {
|
||||||
svc := setupTests(t, func(svc *Service) {
|
svc := setupTests(t, func(svc *Service) {
|
||||||
svc.clients["fake"] = &authntest.FakeClient{}
|
svc.clients["fake"] = &authntest.FakeClient{
|
||||||
|
ExpectedErr: tt.expectedErr,
|
||||||
|
ExpectedTest: tt.expectedOK,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := svc.Authenticate(context.Background(), tt.clientName, &authn.Request{})
|
_, ok, err := svc.Authenticate(context.Background(), tt.clientName, &authn.Request{})
|
||||||
assert.ErrorIs(t, tt.expectedErr, err)
|
assert.Equal(t, tt.expectedOK, ok)
|
||||||
|
if tt.expectedErr != nil {
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,9 +14,14 @@ var _ authn.Client = new(FakeClient)
|
|||||||
|
|
||||||
type FakeClient struct {
|
type FakeClient struct {
|
||||||
ExpectedErr error
|
ExpectedErr error
|
||||||
|
ExpectedTest bool
|
||||||
ExpectedIdentity *authn.Identity
|
ExpectedIdentity *authn.Identity
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FakeClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
func (f *FakeClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||||
return f.ExpectedIdentity, f.ExpectedErr
|
return f.ExpectedIdentity, f.ExpectedErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FakeClient) Test(ctx context.Context, r *authn.Request) bool {
|
||||||
|
return f.ExpectedTest
|
||||||
|
}
|
||||||
|
@ -33,9 +33,13 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &authn.Identity{
|
return &authn.Identity{
|
||||||
OrgID: o.ID,
|
OrgID: o.ID,
|
||||||
OrgName: o.Name,
|
OrgName: o.Name,
|
||||||
OrgRoles: map[int64]org.RoleType{o.ID: org.RoleType(a.cfg.AnonymousOrgRole)},
|
OrgRoles: map[int64]org.RoleType{o.ID: org.RoleType(a.cfg.AnonymousOrgRole)},
|
||||||
IsAnonymous: true,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Anonymous) Test(ctx context.Context, r *authn.Request) bool {
|
||||||
|
// If anonymous client is register it can always be used for authentication
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
@ -56,7 +56,7 @@ func TestAnonymous_Authenticate(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, true, identity.IsAnonymous)
|
assert.Equal(t, true, identity.ID == "")
|
||||||
assert.Equal(t, tt.org.ID, identity.OrgID)
|
assert.Equal(t, tt.org.ID, identity.OrgID)
|
||||||
assert.Equal(t, tt.org.Name, identity.OrgName)
|
assert.Equal(t, tt.org.Name, identity.OrgName)
|
||||||
assert.Equal(t, tt.cfg.AnonymousOrgRole, string(identity.Role()))
|
assert.Equal(t, tt.cfg.AnonymousOrgRole, string(identity.Role()))
|
||||||
|
180
pkg/services/authn/clients/api_key.go
Normal file
180
pkg/services/authn/clients/api_key.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
package clients
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/components/apikeygen"
|
||||||
|
apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/apikey"
|
||||||
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
basicPrefix = "Basic "
|
||||||
|
bearerPrefix = "Bearer "
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAPIKeyInvalid = errutil.NewBase(errutil.StatusUnauthorized, "api-key.invalid", errutil.WithPublicMessage("Invalid API key"))
|
||||||
|
ErrAPIKeyExpired = errutil.NewBase(errutil.StatusUnauthorized, "api-key.expired", errutil.WithPublicMessage("Expired API key"))
|
||||||
|
ErrAPIKeyRevoked = errutil.NewBase(errutil.StatusUnauthorized, "api-key.revoked", errutil.WithPublicMessage("Revoked API key"))
|
||||||
|
ErrServiceAccountDisabled = errutil.NewBase(errutil.StatusUnauthorized, "service-account.disabled", errutil.WithPublicMessage("Disabled service account"))
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ authn.Client = new(APIKey)
|
||||||
|
|
||||||
|
func ProvideAPIKey(apiKeyService apikey.Service, userService user.Service) *APIKey {
|
||||||
|
return &APIKey{
|
||||||
|
log: log.New(authn.ClientAPIKey),
|
||||||
|
userService: userService,
|
||||||
|
apiKeyService: apiKeyService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
log log.Logger
|
||||||
|
userService user.Service
|
||||||
|
apiKeyService apikey.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||||
|
apiKey, err := s.getAPIKey(ctx, getTokenFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, apikeygen.ErrInvalidApiKey) {
|
||||||
|
return nil, ErrAPIKeyInvalid.Errorf("API key is invalid")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey.Expires != nil && *apiKey.Expires <= time.Now().Unix() {
|
||||||
|
return nil, ErrAPIKeyExpired.Errorf("API key has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey.IsRevoked != nil && *apiKey.IsRevoked {
|
||||||
|
return nil, ErrAPIKeyRevoked.Errorf("Api key is revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(id int64) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
s.log.Error("api key authentication panic", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := s.apiKeyService.UpdateAPIKeyLastUsedDate(context.Background(), id); err != nil {
|
||||||
|
s.log.Warn("failed to update last use date for api key", "id", id)
|
||||||
|
}
|
||||||
|
}(apiKey.Id)
|
||||||
|
|
||||||
|
// if the api key don't belong to a service account construct the identity and return it
|
||||||
|
if apiKey.ServiceAccountId == nil || *apiKey.ServiceAccountId < 1 {
|
||||||
|
return &authn.Identity{
|
||||||
|
ID: fmt.Sprintf("%s%d", authn.APIKeyIDPrefix, apiKey.Id),
|
||||||
|
OrgID: apiKey.OrgId,
|
||||||
|
OrgRoles: map[int64]org.RoleType{apiKey.OrgId: apiKey.Role},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
usr, err := s.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{
|
||||||
|
UserID: *apiKey.ServiceAccountId,
|
||||||
|
OrgID: apiKey.OrgId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if usr.IsDisabled {
|
||||||
|
return nil, ErrServiceAccountDisabled.Errorf("Disabled service account")
|
||||||
|
}
|
||||||
|
|
||||||
|
return authn.IdentityFromSignedInUser(fmt.Sprintf("%s%d", authn.ServiceAccountIDPrefix, *apiKey.ServiceAccountId), usr), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) {
|
||||||
|
fn := s.getFromToken
|
||||||
|
if !strings.HasPrefix(token, apikeygenprefix.GrafanaPrefix) {
|
||||||
|
fn = s.getFromTokenLegacy
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err := fn(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKey) getFromToken(ctx context.Context, token string) (*apikey.APIKey, error) {
|
||||||
|
decoded, err := apikeygenprefix.Decode(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := decoded.Hash()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.apiKeyService.GetAPIKeyByHash(ctx, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKey) getFromTokenLegacy(ctx context.Context, token string) (*apikey.APIKey, error) {
|
||||||
|
decoded, err := apikeygen.Decode(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch key
|
||||||
|
keyQuery := apikey.GetByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId}
|
||||||
|
if err := s.apiKeyService.GetApiKeyByName(ctx, &keyQuery); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate api key
|
||||||
|
isValid, err := apikeygen.IsValid(decoded, keyQuery.Result.Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isValid {
|
||||||
|
return nil, apikeygen.ErrInvalidApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyQuery.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKey) Test(ctx context.Context, r *authn.Request) bool {
|
||||||
|
return looksLikeApiKey(getTokenFromRequest(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeApiKey(token string) bool {
|
||||||
|
return token != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTokenFromRequest(r *authn.Request) string {
|
||||||
|
// api keys are only supported through http requests
|
||||||
|
if r.HTTPRequest == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
header := r.HTTPRequest.Header.Get("Authorization")
|
||||||
|
|
||||||
|
if strings.HasPrefix(header, bearerPrefix) {
|
||||||
|
return strings.TrimPrefix(header, bearerPrefix)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(header, basicPrefix) {
|
||||||
|
username, password, err := util.DecodeBasicAuthHeader(header)
|
||||||
|
if err == nil && username == "api_key" {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
207
pkg/services/authn/clients/api_key_test.go
Normal file
207
pkg/services/authn/clients/api_key_test.go
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
package clients
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/components/apikeygen"
|
||||||
|
apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed"
|
||||||
|
"github.com/grafana/grafana/pkg/services/apikey"
|
||||||
|
"github.com/grafana/grafana/pkg/services/apikey/apikeytest"
|
||||||
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
revoked = true
|
||||||
|
secret, hash = genApiKey(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIKey_Authenticate(t *testing.T) {
|
||||||
|
type TestCase struct {
|
||||||
|
desc string
|
||||||
|
req *authn.Request
|
||||||
|
expectedKey *apikey.APIKey
|
||||||
|
expectedUser *user.SignedInUser
|
||||||
|
expectedErr error
|
||||||
|
expectedIdentity *authn.Identity
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []TestCase{
|
||||||
|
{
|
||||||
|
desc: "should success for valid token that is not connected to a service account",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{
|
||||||
|
Header: map[string][]string{
|
||||||
|
"Authorization": {"Bearer " + secret},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
expectedKey: &apikey.APIKey{
|
||||||
|
Id: 1,
|
||||||
|
OrgId: 1,
|
||||||
|
Key: hash,
|
||||||
|
Role: org.RoleAdmin,
|
||||||
|
},
|
||||||
|
expectedIdentity: &authn.Identity{
|
||||||
|
ID: "api-key:1",
|
||||||
|
OrgID: 1,
|
||||||
|
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should success for valid token that is connected to service account",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{
|
||||||
|
Header: map[string][]string{
|
||||||
|
"Authorization": {"Bearer " + secret},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
expectedKey: &apikey.APIKey{
|
||||||
|
Id: 1,
|
||||||
|
OrgId: 1,
|
||||||
|
Key: hash,
|
||||||
|
ServiceAccountId: intPtr(1),
|
||||||
|
},
|
||||||
|
expectedUser: &user.SignedInUser{
|
||||||
|
UserID: 1,
|
||||||
|
OrgID: 1,
|
||||||
|
IsServiceAccount: true,
|
||||||
|
OrgCount: 1,
|
||||||
|
OrgRole: org.RoleViewer,
|
||||||
|
Name: "test",
|
||||||
|
},
|
||||||
|
expectedIdentity: &authn.Identity{
|
||||||
|
ID: "service-account:1",
|
||||||
|
OrgID: 1,
|
||||||
|
OrgCount: 1,
|
||||||
|
Name: "test",
|
||||||
|
OrgRoles: map[int64]org.RoleType{1: org.RoleViewer},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should fail for expired api key",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{"Authorization": {"Bearer " + secret}}}},
|
||||||
|
expectedKey: &apikey.APIKey{
|
||||||
|
Key: hash,
|
||||||
|
Expires: intPtr(0),
|
||||||
|
},
|
||||||
|
expectedErr: ErrAPIKeyExpired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should fail for revoked api key",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{"Authorization": {"Bearer " + secret}}}},
|
||||||
|
expectedKey: &apikey.APIKey{
|
||||||
|
Key: hash,
|
||||||
|
IsRevoked: &revoked,
|
||||||
|
},
|
||||||
|
expectedErr: ErrAPIKeyRevoked,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should fail if service account is disabled",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{"Authorization": {"Bearer " + secret}}}},
|
||||||
|
expectedKey: &apikey.APIKey{
|
||||||
|
Key: hash,
|
||||||
|
ServiceAccountId: intPtr(1),
|
||||||
|
},
|
||||||
|
expectedUser: &user.SignedInUser{IsDisabled: true},
|
||||||
|
expectedErr: ErrServiceAccountDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.desc, func(t *testing.T) {
|
||||||
|
c := ProvideAPIKey(&apikeytest.Service{
|
||||||
|
ExpectedAPIKey: tt.expectedKey,
|
||||||
|
}, &usertest.FakeUserService{
|
||||||
|
ExpectedSignedInUser: tt.expectedUser,
|
||||||
|
})
|
||||||
|
|
||||||
|
identity, err := c.Authenticate(context.Background(), tt.req)
|
||||||
|
if tt.expectedErr != nil {
|
||||||
|
assert.Nil(t, identity)
|
||||||
|
assert.ErrorIs(t, err, tt.expectedErr)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, *tt.expectedIdentity, *identity)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKey_Test(t *testing.T) {
|
||||||
|
type TestCase struct {
|
||||||
|
desc string
|
||||||
|
req *authn.Request
|
||||||
|
expected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []TestCase{
|
||||||
|
{
|
||||||
|
desc: "should succeed when api key is provided in Authorization header as bearer token",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{
|
||||||
|
Header: map[string][]string{
|
||||||
|
"Authorization": {"Bearer 123123"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should succeed when api key is provided in Authorization header as basic auth and api_key as username",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{
|
||||||
|
Header: map[string][]string{
|
||||||
|
"Authorization": {encodeBasicAuth("api_key", "test")},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should fail when no http request is passed",
|
||||||
|
req: &authn.Request{},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should fail when no there is no Authorization header",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{
|
||||||
|
Header: map[string][]string{},
|
||||||
|
}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "should fail when Authorization header is not prefixed with Basic or Bearer",
|
||||||
|
req: &authn.Request{HTTPRequest: &http.Request{
|
||||||
|
Header: map[string][]string{
|
||||||
|
"Authorization": {"test"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.desc, func(t *testing.T) {
|
||||||
|
c := ProvideAPIKey(&apikeytest.Service{}, usertest.NewUserServiceFake())
|
||||||
|
assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(n int64) *int64 {
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func genApiKey(legacy bool) (string, string) {
|
||||||
|
if legacy {
|
||||||
|
res, _ := apikeygen.New(1, "test")
|
||||||
|
return res.ClientSecret, res.HashedKey
|
||||||
|
}
|
||||||
|
res, _ := apikeygenprefix.New("test")
|
||||||
|
return res.ClientSecret, res.HashedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeBasicAuth(username, password string) string {
|
||||||
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
|
||||||
|
}
|
@ -34,6 +34,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -68,6 +69,7 @@ func ProvideService(cfg *setting.Cfg, tokenService auth.UserTokenService, jwtSer
|
|||||||
orgService: orgService,
|
orgService: orgService,
|
||||||
oauthTokenService: oauthTokenService,
|
oauthTokenService: oauthTokenService,
|
||||||
features: features,
|
features: features,
|
||||||
|
authnService: authnService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,8 +202,8 @@ func (h *ContextHandler) initContextWithAnonymousUser(reqContext *models.ReqCont
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
||||||
identity, err := h.authnService.Authenticate(ctx, authn.ClientAnonymous, &authn.Request{HTTPRequest: reqContext.Req})
|
identity, ok, err := h.authnService.Authenticate(ctx, authn.ClientAnonymous, &authn.Request{HTTPRequest: reqContext.Req})
|
||||||
if err != nil {
|
if !ok || err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
reqContext.SignedInUser = identity.SignedInUser()
|
reqContext.SignedInUser = identity.SignedInUser()
|
||||||
@ -271,6 +273,26 @@ func (h *ContextHandler) getAPIKey(ctx context.Context, keyString string) (*apik
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bool {
|
func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bool {
|
||||||
|
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
||||||
|
identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientAPIKey, &authn.Request{HTTPRequest: reqContext.Req})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// include auth header in context
|
||||||
|
ctx := WithAuthHTTPHeader(reqContext.Req.Context(), "Authorization")
|
||||||
|
*reqContext.Req = *reqContext.Req.WithContext(ctx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeErr(reqContext, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
reqContext.IsSignedIn = true
|
||||||
|
reqContext.SignedInUser = identity.SignedInUser()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
header := reqContext.Req.Header.Get("Authorization")
|
header := reqContext.Req.Header.Get("Authorization")
|
||||||
parts := strings.SplitN(header, " ", 2)
|
parts := strings.SplitN(header, " ", 2)
|
||||||
var keyString string
|
var keyString string
|
||||||
@ -709,6 +731,16 @@ func (h *ContextHandler) initContextWithAuthProxy(reqContext *models.ReqContext,
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeErr will write error response based on errutil.Error.
|
||||||
|
func writeErr(c *models.ReqContext, err error) {
|
||||||
|
grfErr := &errutil.Error{}
|
||||||
|
if !errors.As(err, grfErr) {
|
||||||
|
c.JsonApiErr(http.StatusInternalServerError, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JsonApiErr(grfErr.Reason.Status().HTTPStatus(), grfErr.Public().Message, err)
|
||||||
|
}
|
||||||
|
|
||||||
type authHTTPHeaderListContextKey struct{}
|
type authHTTPHeaderListContextKey struct{}
|
||||||
|
|
||||||
var authHTTPHeaderListKey = authHTTPHeaderListContextKey{}
|
var authHTTPHeaderListKey = authHTTPHeaderListContextKey{}
|
||||||
|
Loading…
Reference in New Issue
Block a user