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:
Karl Persson 2023-01-03 10:23:38 +01:00 committed by GitHub
parent b3540b5f46
commit 9fbb29c588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 302 additions and 28 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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))
}

View 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
}

View 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))
})
}
}

View File

@ -0,0 +1,7 @@
package clients
const (
basicPrefix = "Basic "
bearerPrefix = "Bearer "
authorizationHeaderName = "Authorization"
)

View File

@ -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
}