diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 8f81c437c64..6d5f1bb4687 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -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, diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index 602db60823e..bf58c809a2e 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -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) diff --git a/pkg/services/authn/authnimpl/usersync/orgsync.go b/pkg/services/authn/authnimpl/usersync/orgsync.go index fa2e4fa677e..8aba2b781c5 100644 --- a/pkg/services/authn/authnimpl/usersync/orgsync.go +++ b/pkg/services/authn/authnimpl/usersync/orgsync.go @@ -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 diff --git a/pkg/services/authn/authnimpl/usersync/usersync.go b/pkg/services/authn/authnimpl/usersync/usersync.go index 69f4d12995a..33db88f8645 100644 --- a/pkg/services/authn/authnimpl/usersync/usersync.go +++ b/pkg/services/authn/authnimpl/usersync/usersync.go @@ -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 diff --git a/pkg/services/authn/clients/api_key.go b/pkg/services/authn/clients/api_key.go index 9eeddba0d68..73a8213613e 100644 --- a/pkg/services/authn/clients/api_key.go +++ b/pkg/services/authn/clients/api_key.go @@ -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)) } diff --git a/pkg/services/authn/clients/basic.go b/pkg/services/authn/clients/basic.go new file mode 100644 index 00000000000..fea5c39e911 --- /dev/null +++ b/pkg/services/authn/clients/basic.go @@ -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 +} diff --git a/pkg/services/authn/clients/basic_test.go b/pkg/services/authn/clients/basic_test.go new file mode 100644 index 00000000000..727b8a66c9f --- /dev/null +++ b/pkg/services/authn/clients/basic_test.go @@ -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)) + }) + } +} diff --git a/pkg/services/authn/clients/constants.go b/pkg/services/authn/clients/constants.go new file mode 100644 index 00000000000..1cddacfdfaf --- /dev/null +++ b/pkg/services/authn/clients/constants.go @@ -0,0 +1,7 @@ +package clients + +const ( + basicPrefix = "Basic " + bearerPrefix = "Bearer " + authorizationHeaderName = "Authorization" +) diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 36df35cfa38..7daa0605eed 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -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 }