mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
AuthN: Add client to perform basic authentication (#60877)
* AuthN: Add basic auth client boilerplate * AuthN: Implement test function for basic auth client * AuthN: Implement the authentication method for basic auth * AuthN: Add tests for basic auth authentication * ContextHandler: perform basic auth authentication through authn service if feature toggle is enabled * AuthN: Add providers for sync services and pass required dependencies
This commit is contained in:
parent
b3540b5f46
commit
9fbb29c588
@ -2,6 +2,7 @@ package authn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -16,6 +17,7 @@ import (
|
||||
const (
|
||||
ClientAPIKey = "auth.client.api-key" // #nosec G101
|
||||
ClientAnonymous = "auth.client.anonymous"
|
||||
ClientBasic = "auth.client.basic"
|
||||
)
|
||||
|
||||
type ClientParams struct {
|
||||
@ -48,8 +50,9 @@ type Request struct {
|
||||
}
|
||||
|
||||
const (
|
||||
APIKeyIDPrefix = "api-key:"
|
||||
ServiceAccountIDPrefix = "service-account:"
|
||||
NamespaceUser = "user"
|
||||
NamespaceAPIKey = "api-key"
|
||||
NamespaceServiceAccount = "service-account"
|
||||
)
|
||||
|
||||
type Identity struct {
|
||||
@ -105,6 +108,11 @@ func (i *Identity) NamespacedID() (string, int64) {
|
||||
}
|
||||
|
||||
func (i *Identity) SignedInUser() *user.SignedInUser {
|
||||
var isGrafanaAdmin bool
|
||||
if i.IsGrafanaAdmin != nil {
|
||||
isGrafanaAdmin = *i.IsGrafanaAdmin
|
||||
}
|
||||
|
||||
u := &user.SignedInUser{
|
||||
UserID: 0,
|
||||
OrgID: i.OrgID,
|
||||
@ -116,7 +124,7 @@ func (i *Identity) SignedInUser() *user.SignedInUser {
|
||||
Name: i.Name,
|
||||
Email: i.Email,
|
||||
OrgCount: i.OrgCount,
|
||||
IsGrafanaAdmin: *i.IsGrafanaAdmin,
|
||||
IsGrafanaAdmin: isGrafanaAdmin,
|
||||
IsAnonymous: i.IsAnonymous(),
|
||||
IsDisabled: i.IsDisabled,
|
||||
HelpFlags1: i.HelpFlags1,
|
||||
@ -124,19 +132,21 @@ func (i *Identity) SignedInUser() *user.SignedInUser {
|
||||
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)
|
||||
namespace, id := i.NamespacedID()
|
||||
if namespace == NamespaceAPIKey {
|
||||
u.ApiKeyID = id
|
||||
} else if strings.HasPrefix(i.ID, ServiceAccountIDPrefix) {
|
||||
id, _ := strconv.ParseInt(strings.TrimPrefix(i.ID, ServiceAccountIDPrefix), 10, 64)
|
||||
} else {
|
||||
u.UserID = id
|
||||
u.IsServiceAccount = true
|
||||
u.IsServiceAccount = namespace == NamespaceServiceAccount
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func NamespacedID(namespace string, id int64) string {
|
||||
return fmt.Sprintf("%s:%d", namespace, id)
|
||||
}
|
||||
|
||||
func IdentityFromSignedInUser(id string, usr *user.SignedInUser) *Identity {
|
||||
return &Identity{
|
||||
ID: id,
|
||||
|
@ -7,11 +7,15 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
sync "github.com/grafana/grafana/pkg/services/authn/authnimpl/usersync"
|
||||
"github.com/grafana/grafana/pkg/services/authn/clients"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/loginattempt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@ -20,7 +24,11 @@ import (
|
||||
// make sure service implements authn.Service interface
|
||||
var _ authn.Service = new(Service)
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service, apikeyService apikey.Service, userService user.Service) *Service {
|
||||
func ProvideService(
|
||||
cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service, accessControlService accesscontrol.Service,
|
||||
apikeyService apikey.Service, userService user.Service, loginAttempts loginattempt.Service, quotaService quota.Service,
|
||||
authInfoService login.AuthInfoService,
|
||||
) *Service {
|
||||
s := &Service{
|
||||
log: log.New("authn.service"),
|
||||
cfg: cfg,
|
||||
@ -35,9 +43,14 @@ func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Serv
|
||||
s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService)
|
||||
}
|
||||
|
||||
// FIXME (kalleep): handle cfg.DisableLogin as well?
|
||||
if s.cfg.BasicAuthEnabled && !s.cfg.DisableLogin {
|
||||
s.clients[authn.ClientBasic] = clients.ProvideBasic(userService, loginAttempts)
|
||||
}
|
||||
|
||||
// FIXME (jguer): move to User package
|
||||
userSyncService := &sync.UserSync{}
|
||||
orgUserSyncService := &sync.OrgSync{}
|
||||
userSyncService := sync.ProvideUserSync(userService, authInfoService, quotaService)
|
||||
orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService)
|
||||
s.RegisterPostAuthHook(userSyncService.SyncUser)
|
||||
s.RegisterPostAuthHook(orgUserSyncService.SyncOrgUser)
|
||||
|
||||
|
@ -15,6 +15,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func ProvideOrgSync(userService user.Service, orgService org.Service, accessControl accesscontrol.Service) *OrgSync {
|
||||
return &OrgSync{userService, orgService, accessControl, log.New("org.sync")}
|
||||
}
|
||||
|
||||
type OrgSync struct {
|
||||
userService user.Service
|
||||
orgService org.Service
|
||||
|
@ -13,6 +13,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func ProvideUserSync(userService user.Service, authInfoService login.AuthInfoService, quotaService quota.Service) *UserSync {
|
||||
return &UserSync{userService, authInfoService, quotaService, log.New("user.sync")}
|
||||
}
|
||||
|
||||
type UserSync struct {
|
||||
userService user.Service
|
||||
authInfoService login.AuthInfoService
|
||||
|
@ -3,7 +3,6 @@ package clients
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -18,11 +17,6 @@ import (
|
||||
"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"))
|
||||
@ -46,14 +40,6 @@ type APIKey struct {
|
||||
apiKeyService apikey.Service
|
||||
}
|
||||
|
||||
func (s *APIKey) ClientParams() *authn.ClientParams {
|
||||
return &authn.ClientParams{
|
||||
SyncUser: false,
|
||||
AllowSignUp: false,
|
||||
EnableDisabledUsers: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||
apiKey, err := s.getAPIKey(ctx, getTokenFromRequest(r))
|
||||
if err != nil {
|
||||
@ -85,7 +71,7 @@ func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Ide
|
||||
// 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),
|
||||
ID: authn.NamespacedID(authn.NamespaceAPIKey, apiKey.Id),
|
||||
OrgID: apiKey.OrgId,
|
||||
OrgRoles: map[int64]org.RoleType{apiKey.OrgId: apiKey.Role},
|
||||
}, nil
|
||||
@ -104,7 +90,7 @@ func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Ide
|
||||
return nil, ErrServiceAccountDisabled.Errorf("Disabled service account")
|
||||
}
|
||||
|
||||
return authn.IdentityFromSignedInUser(fmt.Sprintf("%s%d", authn.ServiceAccountIDPrefix, *apiKey.ServiceAccountId), usr), nil
|
||||
return authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceServiceAccount, usr.UserID), usr), nil
|
||||
}
|
||||
|
||||
func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) {
|
||||
@ -159,6 +145,10 @@ func (s *APIKey) getFromTokenLegacy(ctx context.Context, token string) (*apikey.
|
||||
return keyQuery.Result, nil
|
||||
}
|
||||
|
||||
func (s *APIKey) ClientParams() *authn.ClientParams {
|
||||
return &authn.ClientParams{}
|
||||
}
|
||||
|
||||
func (s *APIKey) Test(ctx context.Context, r *authn.Request) bool {
|
||||
return looksLikeApiKey(getTokenFromRequest(r))
|
||||
}
|
||||
|
105
pkg/services/authn/clients/basic.go
Normal file
105
pkg/services/authn/clients/basic.go
Normal file
@ -0,0 +1,105 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/loginattempt"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBasicAuthCredentials = errutil.NewBase(errutil.StatusUnauthorized, "basic-auth.invalid-credentials", errutil.WithPublicMessage("Invalid username or password"))
|
||||
ErrDecodingBasicAuthHeader = errutil.NewBase(errutil.StatusBadRequest, "basic-auth.invalid-header", errutil.WithPublicMessage("Invalid Basic Auth Header"))
|
||||
)
|
||||
|
||||
var _ authn.Client = new(Basic)
|
||||
|
||||
func ProvideBasic(userService user.Service, loginAttempts loginattempt.Service) *Basic {
|
||||
return &Basic{userService, loginAttempts}
|
||||
}
|
||||
|
||||
type Basic struct {
|
||||
userService user.Service
|
||||
loginAttempts loginattempt.Service
|
||||
}
|
||||
|
||||
func (c *Basic) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||
username, password, err := util.DecodeBasicAuthHeader(getBasicAuthHeaderFromRequest(r))
|
||||
if err != nil {
|
||||
return nil, ErrDecodingBasicAuthHeader.Errorf("failed to decode basic auth header: %w", err)
|
||||
}
|
||||
|
||||
ok, err := c.loginAttempts.Validate(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrBasicAuthCredentials.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
|
||||
}
|
||||
|
||||
if len(password) == 0 {
|
||||
return nil, ErrBasicAuthCredentials.Errorf("no password provided")
|
||||
}
|
||||
|
||||
// FIXME (kalleep): decide if we should handle ldap here
|
||||
usr, err := c.userService.GetByLogin(ctx, &user.GetUserByLoginQuery{LoginOrEmail: username})
|
||||
if err != nil {
|
||||
return nil, ErrBasicAuthCredentials.Errorf("failed to fetch user: %w", err)
|
||||
}
|
||||
|
||||
if ok := comparePassword(password, usr.Salt, usr.Password); !ok {
|
||||
_ = c.loginAttempts.Add(ctx, username, r.HTTPRequest.RemoteAddr)
|
||||
return nil, ErrBasicAuthCredentials.Errorf("incorrect password provided")
|
||||
}
|
||||
|
||||
signedInUser, err := c.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{
|
||||
UserID: usr.ID,
|
||||
OrgID: r.OrgID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrBasicAuthCredentials.Errorf("failed to fetch user: %w", err)
|
||||
}
|
||||
|
||||
return authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, signedInUser.UserID), signedInUser), nil
|
||||
}
|
||||
|
||||
func (c *Basic) ClientParams() *authn.ClientParams {
|
||||
return &authn.ClientParams{}
|
||||
}
|
||||
|
||||
func (c *Basic) Test(ctx context.Context, r *authn.Request) bool {
|
||||
return looksLikeBasicAuthRequest(r)
|
||||
}
|
||||
|
||||
func looksLikeBasicAuthRequest(r *authn.Request) bool {
|
||||
return getBasicAuthHeaderFromRequest(r) != ""
|
||||
}
|
||||
|
||||
func getBasicAuthHeaderFromRequest(r *authn.Request) string {
|
||||
if r.HTTPRequest == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := r.HTTPRequest.Header.Get(authorizationHeaderName)
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(header, basicPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
func comparePassword(password, salt, hash string) bool {
|
||||
// It is ok to ignore the error here because util.EncodePassword can never return a error
|
||||
hashedPassword, _ := util.EncodePassword(password, salt)
|
||||
return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(hash)) == 1
|
||||
}
|
121
pkg/services/authn/clients/basic_test.go
Normal file
121
pkg/services/authn/clients/basic_test.go
Normal file
@ -0,0 +1,121 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBasic_Authenticate(t *testing.T) {
|
||||
type TestCase struct {
|
||||
desc string
|
||||
req *authn.Request
|
||||
blockLogin bool
|
||||
expectedErr error
|
||||
expectedSignedInUser *user.SignedInUser
|
||||
expectedIdentity *authn.Identity
|
||||
}
|
||||
|
||||
tests := []TestCase{
|
||||
{
|
||||
desc: "should successfully authenticate user with correct password",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}}},
|
||||
expectedErr: nil,
|
||||
expectedSignedInUser: &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: "Viewer"},
|
||||
expectedIdentity: &authn.Identity{ID: "user:1", OrgID: 1, OrgRoles: map[int64]org.RoleType{1: "Viewer"}, IsGrafanaAdmin: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
desc: "should fail for incorrect password",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "wrong")}}}},
|
||||
expectedErr: ErrBasicAuthCredentials,
|
||||
},
|
||||
{
|
||||
desc: "should fail for empty password",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "")}}}},
|
||||
expectedErr: ErrBasicAuthCredentials,
|
||||
},
|
||||
{
|
||||
desc: "should if login is blocked by to many attempts",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "")}}}},
|
||||
blockLogin: true,
|
||||
expectedErr: ErrBasicAuthCredentials,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
hashed, _ := util.EncodePassword("password", "salt")
|
||||
c := ProvideBasic(&usertest.FakeUserService{
|
||||
ExpectedUser: &user.User{
|
||||
Password: hashed,
|
||||
Salt: "salt",
|
||||
},
|
||||
ExpectedSignedInUser: tt.expectedSignedInUser,
|
||||
}, loginattempttest.FakeLoginAttemptService{
|
||||
ExpectedValid: !tt.blockLogin,
|
||||
})
|
||||
|
||||
identity, err := c.Authenticate(context.Background(), tt.req)
|
||||
if tt.expectedErr != nil {
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
assert.Nil(t, identity)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, *tt.expectedIdentity, *identity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasic_Test(t *testing.T) {
|
||||
type TestCase struct {
|
||||
desc string
|
||||
req *authn.Request
|
||||
expected bool
|
||||
}
|
||||
|
||||
tests := []TestCase{
|
||||
{
|
||||
desc: "should succeed when authorization header is set with basic prefix",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{
|
||||
authorizationHeaderName: {encodeBasicAuth("user", "password")},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "should fail when no http request is passed",
|
||||
req: &authn.Request{},
|
||||
},
|
||||
{
|
||||
desc: "should fail when no http authorization header is set in http request",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{Header: map[string][]string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should fail when authorization header is set but without basic prefix",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {"something"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
c := ProvideBasic(usertest.NewUserServiceFake(), loginattempttest.FakeLoginAttemptService{})
|
||||
assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req))
|
||||
})
|
||||
}
|
||||
}
|
7
pkg/services/authn/clients/constants.go
Normal file
7
pkg/services/authn/clients/constants.go
Normal file
@ -0,0 +1,7 @@
|
||||
package clients
|
||||
|
||||
const (
|
||||
basicPrefix = "Basic "
|
||||
bearerPrefix = "Bearer "
|
||||
authorizationHeaderName = "Authorization"
|
||||
)
|
@ -396,6 +396,26 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo
|
||||
}
|
||||
|
||||
func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, orgID int64) bool {
|
||||
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
||||
identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientBasic, &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
|
||||
}
|
||||
|
||||
if !h.Cfg.BasicAuthEnabled {
|
||||
return false
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user