From 49673c509d54e522171bf8afc9aaf9ca01a3c1f3 Mon Sep 17 00:00:00 2001 From: bergquist Date: Thu, 25 Jan 2018 11:05:59 +0100 Subject: [PATCH 01/24] fixes broken phantomjs rendering when migrating from govendor to dep we broke the phantomjs rendering. ref #10602 --- .gitignore | 4 +- docs/sources/reference/sharing.md | 2 +- pkg/setting/setting.go | 2 +- scripts/grunt/options/phantomjs.js | 2 +- scripts/grunt/release_task.js | 2 +- tools/phantomjs/render.js | 86 ++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 tools/phantomjs/render.js diff --git a/.gitignore b/.gitignore index deb1c1f882a..72f6684ef20 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ awsconfig /public_gen /public/vendor/npm /tmp -vendor/phantomjs/phantomjs -vendor/phantomjs/phantomjs.exe +tools/phantomjs/phantomjs +tools/phantomjs/phantomjs.exe profile.out coverage.txt diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index badd3b5712a..20aea1acd2e 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -39,7 +39,7 @@ Click a panel title to open the panel menu, then click share in the panel menu t ### Direct Link Rendered Image -You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `vendor/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available. +You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `tools/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available. Example of a link to a server-side rendered PNG: diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8cdb94bd413..d236446eb71 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -578,7 +578,7 @@ func NewConfigContext(args *CommandLineArgs) error { // PhantomJS rendering ImagesDir = filepath.Join(DataPath, "png") - PhantomDir = filepath.Join(HomePath, "vendor/phantomjs") + PhantomDir = filepath.Join(HomePath, "tools/phantomjs") analytics := Cfg.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) diff --git a/scripts/grunt/options/phantomjs.js b/scripts/grunt/options/phantomjs.js index af7deff305c..8c4e8dbd254 100644 --- a/scripts/grunt/options/phantomjs.js +++ b/scripts/grunt/options/phantomjs.js @@ -3,7 +3,7 @@ module.exports = function(config,grunt) { grunt.registerTask('phantomjs', 'Copy phantomjs binary to vendor/', function() { - var dest = './vendor/phantomjs/phantomjs'; + var dest = './tools/phantomjs/phantomjs'; var confDir = './node_modules/phantomjs-prebuilt/lib/'; if (process.platform === "win32") { diff --git a/scripts/grunt/release_task.js b/scripts/grunt/release_task.js index 28208ed0086..4fbc41cfb08 100644 --- a/scripts/grunt/release_task.js +++ b/scripts/grunt/release_task.js @@ -26,7 +26,7 @@ module.exports = function(grunt) { }); grunt.config('copy.backend_files', { expand: true, - src: ['conf/**', 'vendor/phantomjs/*', 'scripts/*'], + src: ['conf/**', 'tools/phantomjs/*', 'scripts/*'], options: { mode: true}, dest: '<%= tempDir %>' }); diff --git a/tools/phantomjs/render.js b/tools/phantomjs/render.js new file mode 100644 index 00000000000..6ae9b5773b0 --- /dev/null +++ b/tools/phantomjs/render.js @@ -0,0 +1,86 @@ +(function() { + 'use strict'; + + var page = require('webpage').create(); + var args = require('system').args; + var params = {}; + var regexp = /^([^=]+)=([^$]+)/; + + args.forEach(function(arg) { + var parts = arg.match(regexp); + if (!parts) { return; } + params[parts[1]] = parts[2]; + }); + + var usage = "url= png= width= height= renderKey="; + + if (!params.url || !params.png || !params.renderKey || !params.domain) { + console.log(usage); + phantom.exit(); + } + + phantom.addCookie({ + 'name': 'renderKey', + 'value': params.renderKey, + 'domain': params.domain, + }); + + page.viewportSize = { + width: params.width || '800', + height: params.height || '400' + }; + + var timeoutMs = (parseInt(params.timeout) || 10) * 1000; + var waitBetweenReadyCheckMs = 50; + var totalWaitMs = 0; + + page.open(params.url, function (status) { + console.log('Loading a web page: ' + params.url + ' status: ' + status, timeoutMs); + + page.onError = function(msg, trace) { + var msgStack = ['ERROR: ' + msg]; + if (trace && trace.length) { + msgStack.push('TRACE:'); + trace.forEach(function(t) { + msgStack.push(' -> ' + t.file + ': ' + t.line + (t.function ? ' (in function "' + t.function +'")' : '')); + }); + } + console.error(msgStack.join('\n')); + }; + + function checkIsReady() { + var panelsRendered = page.evaluate(function() { + if (!window.angular) { return false; } + var body = window.angular.element(document.body); + if (!body.injector) { return false; } + if (!body.injector()) { return false; } + + var rootScope = body.injector().get('$rootScope'); + if (!rootScope) {return false;} + var panels = angular.element('div.panel:visible').length; + return rootScope.panelsRendered >= panels; + }); + + if (panelsRendered || totalWaitMs > timeoutMs) { + var bb = page.evaluate(function () { + return document.getElementsByClassName("main-view")[0].getBoundingClientRect(); + }); + + page.clipRect = { + top: bb.top, + left: bb.left, + width: bb.width, + height: bb.height + }; + + page.render(params.png); + phantom.exit(); + } else { + totalWaitMs += waitBetweenReadyCheckMs; + setTimeout(checkIsReady, waitBetweenReadyCheckMs); + } + } + + setTimeout(checkIsReady, waitBetweenReadyCheckMs); + }); + })(); \ No newline at end of file From b79017e4a48bf0f1ced4f743b51fef93a42afa76 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 26 Jan 2018 11:23:56 +0300 Subject: [PATCH 02/24] graph: fix series sorting issue (#10617) --- public/app/plugins/panel/graph/graph.ts | 31 ++++++------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 91ca17c52f6..e23cad63305 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -355,33 +355,16 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) { function sortSeries(series, panel) { var sortBy = panel.legend.sort; var sortOrder = panel.legend.sortDesc; - var haveSortBy = sortBy !== null || sortBy !== undefined; - var haveSortOrder = sortOrder !== null || sortOrder !== undefined; + var haveSortBy = sortBy !== null && sortBy !== undefined; + var haveSortOrder = sortOrder !== null && sortOrder !== undefined; var shouldSortBy = panel.stack && haveSortBy && haveSortOrder; var sortDesc = panel.legend.sortDesc === true ? -1 : 1; - series.sort((x, y) => { - if (x.zindex > y.zindex) { - return 1; - } - - if (x.zindex < y.zindex) { - return -1; - } - - if (shouldSortBy) { - if (x.stats[sortBy] > y.stats[sortBy]) { - return 1 * sortDesc; - } - if (x.stats[sortBy] < y.stats[sortBy]) { - return -1 * sortDesc; - } - } - - return 0; - }); - - return series; + if (shouldSortBy) { + return _.sortBy(series, s => s.stats[sortBy] * sortDesc); + } else { + return _.sortBy(series, s => s.zindex); + } } function translateFillOption(fill) { From cffbb6afd59a61032365d5cef1731f25f736a7d3 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 26 Jan 2018 11:24:56 +0300 Subject: [PATCH 03/24] fix vertical panel repeat (#10619) --- .../app/features/dashboard/dashboard_model.ts | 8 +++++ .../features/dashboard/specs/repeat.jest.ts | 34 ++++++------------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 072b38fba22..6c291c3b69a 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -354,6 +354,14 @@ export class DashboardModel { if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { copy.gridPos.y = yPos; yPos += copy.gridPos.h; + + // Update gridPos for panels below + let panelBelowIndex = panelIndex + index + 1; + for (let i = panelBelowIndex; i < this.panels.length; i++) { + if (this.panels[i].gridPos.y < yPos) { + this.panels[i].gridPos.y += copy.gridPos.h; + } + } } else { // set width based on how many are selected // assumed the repeated panels should take up full row width diff --git a/public/app/features/dashboard/specs/repeat.jest.ts b/public/app/features/dashboard/specs/repeat.jest.ts index fbf1f836191..e05162dc69f 100644 --- a/public/app/features/dashboard/specs/repeat.jest.ts +++ b/public/app/features/dashboard/specs/repeat.jest.ts @@ -142,12 +142,9 @@ describe('given dashboard with panel repeat in vertical direction', function() { beforeEach(function() { dashboard = new DashboardModel({ panels: [ - { - id: 2, - repeat: 'apps', - repeatDirection: 'v', - gridPos: { x: 5, y: 0, h: 2, w: 8 }, - }, + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'apps', repeatDirection: 'v', gridPos: { x: 5, y: 1, h: 2, w: 8 } }, + { id: 3, type: 'row', gridPos: { x: 0, y: 3, h: 1, w: 24 } }, ], templating: { list: [ @@ -171,24 +168,13 @@ describe('given dashboard with panel repeat in vertical direction', function() { }); it('should place on items on top of each other and keep witdh', function() { - expect(dashboard.panels[0].gridPos).toMatchObject({ - x: 5, - y: 0, - h: 2, - w: 8, - }); - expect(dashboard.panels[1].gridPos).toMatchObject({ - x: 5, - y: 2, - h: 2, - w: 8, - }); - expect(dashboard.panels[2].gridPos).toMatchObject({ - x: 5, - y: 4, - h: 2, - w: 8, - }); + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // first row + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 5, y: 1, h: 2, w: 8 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 5, y: 3, h: 2, w: 8 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 5, y: 5, h: 2, w: 8 }); + + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 0, y: 7, h: 1, w: 24 }); // last row }); }); From 475febd0048dcd5fc1a161556f0e909efec33405 Mon Sep 17 00:00:00 2001 From: James Westover Date: Fri, 26 Jan 2018 02:27:06 -0600 Subject: [PATCH 04/24] Fix typeahead to avoid generating new backend request on each keypress. (#10596) * Fix typeahead to not generate new request on each keypress. * Change to debounce method --- public/app/core/components/query_part/query_part_editor.ts | 4 ++-- public/app/core/directives/metric_segment.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts index 138da186238..c78ca8d74e6 100644 --- a/public/app/core/components/query_part/query_part_editor.ts +++ b/public/app/core/components/query_part/query_part_editor.ts @@ -123,11 +123,11 @@ export function queryPartEditorDirective($compile, templateSrv) { }); var typeahead = $input.data('typeahead'); - typeahead.lookup = function() { + typeahead.lookup = _.debounce(function() { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }; + }, 500); } $scope.showActionsMenu = function() { diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js index 2754f8d8c6e..b11625227c7 100644 --- a/public/app/core/directives/metric_segment.js +++ b/public/app/core/directives/metric_segment.js @@ -129,11 +129,11 @@ function (_, $, coreModule) { $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher }); var typeahead = $input.data('typeahead'); - typeahead.lookup = function () { + typeahead.lookup = _.debounce(function() { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }; + }, 500); $button.keydown(function(evt) { // trigger typeahead on down arrow or enter key From 3d1c624c12e16da69e9fd1be66173c96502e69a6 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 26 Jan 2018 10:41:41 +0100 Subject: [PATCH 05/24] WIP: Protect against brute force (frequent) login attempts (#10031) * db: add login attempt migrations * db: add possibility to create login attempts * db: add possibility to retrieve login attempt count per username * auth: validation and update of login attempts for invalid credentials If login attempt count for user authenticating is 5 or more the last 5 minutes we temporarily block the user access to login * db: add possibility to delete expired login attempts * cleanup: Delete login attempts older than 10 minutes The cleanup job are running continuously and triggering each 10 minute * fix typo: rename consequent to consequent * auth: enable login attempt validation for ldap logins * auth: disable login attempts validation by configuration Setting is named DisableLoginAttemptsValidation and is false by default Config disable_login_attempts_validation is placed under security section #7616 * auth: don't run cleanup of login attempts if feature is disabled #7616 * auth: rename settings.go to ldap_settings.go * auth: refactor AuthenticateUser Extract grafana login, ldap login and login attemp validation together with their tests to separate files. Enables testing of many more aspects when authenticating a user. #7616 * auth: rename login attempt validation to brute force login protection Setting DisableLoginAttemptsValidation => DisableBruteForceLoginProtection Configuration disable_login_attempts_validation => disable_brute_force_login_protection #7616 --- conf/defaults.ini | 3 + conf/sample.ini | 3 + pkg/api/login.go | 7 +- pkg/login/auth.go | 63 +++--- pkg/login/auth_test.go | 214 ++++++++++++++++++ pkg/login/brute_force_login_protection.go | 48 ++++ .../brute_force_login_protection_test.go | 125 ++++++++++ pkg/login/grafana_login.go | 35 +++ pkg/login/grafana_login_test.go | 139 ++++++++++++ pkg/login/ldap_login.go | 21 ++ pkg/login/ldap_login_test.go | 172 ++++++++++++++ pkg/login/{settings.go => ldap_settings.go} | 0 pkg/models/login_attempt.go | 36 +++ pkg/services/cleanup/cleanup.go | 16 ++ pkg/services/sqlstore/login_attempt.go | 91 ++++++++ pkg/services/sqlstore/login_attempt_test.go | 125 ++++++++++ .../sqlstore/migrations/login_attempt_mig.go | 23 ++ .../sqlstore/migrations/migrations.go | 1 + pkg/services/sqlstore/migrator/dialect.go | 5 + .../sqlstore/migrator/sqlite_dialect.go | 4 + pkg/services/sqlstore/sqlutil/sqlutil.go | 2 +- pkg/setting/setting.go | 16 +- 22 files changed, 1101 insertions(+), 48 deletions(-) create mode 100644 pkg/login/auth_test.go create mode 100644 pkg/login/brute_force_login_protection.go create mode 100644 pkg/login/brute_force_login_protection_test.go create mode 100644 pkg/login/grafana_login.go create mode 100644 pkg/login/grafana_login_test.go create mode 100644 pkg/login/ldap_login.go create mode 100644 pkg/login/ldap_login_test.go rename pkg/login/{settings.go => ldap_settings.go} (100%) create mode 100644 pkg/models/login_attempt.go create mode 100644 pkg/services/sqlstore/login_attempt.go create mode 100644 pkg/services/sqlstore/login_attempt_test.go create mode 100644 pkg/services/sqlstore/migrations/login_attempt_mig.go diff --git a/conf/defaults.ini b/conf/defaults.ini index 5439a373bbb..3766c829323 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -174,6 +174,9 @@ disable_gravatar = false # data source proxy whitelist (ip_or_domain:port separated by spaces) data_source_proxy_whitelist = +# disable protection against brute force login attempts +disable_brute_force_login_protection = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/conf/sample.ini b/conf/sample.ini index 59bd5845ffe..784f6b7cfc9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -162,6 +162,9 @@ log_queries = # data source proxy whitelist (ip_or_domain:port separated by spaces) ;data_source_proxy_whitelist = +# disable protection against brute force login attempts +;disable_brute_force_login_protection = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/pkg/api/login.go b/pkg/api/login.go index ebfe672f825..b6855af7baf 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -102,12 +102,13 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { } authQuery := login.LoginUserQuery{ - Username: cmd.User, - Password: cmd.Password, + Username: cmd.User, + Password: cmd.Password, + IpAddress: c.Req.RemoteAddr, } if err := bus.Dispatch(&authQuery); err != nil { - if err == login.ErrInvalidCredentials { + if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts { return ApiError(401, "Invalid username or password", err) } diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 45561783e43..5527c7271d6 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -3,21 +3,20 @@ package login import ( "errors" - "crypto/subtle" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) var ( - ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") ) type LoginUserQuery struct { - Username string - Password string - User *m.User + Username string + Password string + User *m.User + IpAddress string } func Init() { @@ -26,41 +25,31 @@ func Init() { } func AuthenticateUser(query *LoginUserQuery) error { - err := loginUsingGrafanaDB(query) - if err == nil || err != ErrInvalidCredentials { + if err := validateLoginAttempts(query.Username); err != nil { return err } - if setting.LdapEnabled { - for _, server := range LdapCfg.Servers { - author := NewLdapAuthenticator(server) - err = author.Login(query) - if err == nil || err != ErrInvalidCredentials { - return err - } + err := loginUsingGrafanaDB(query) + if err == nil || (err != m.ErrUserNotFound && err != ErrInvalidCredentials) { + return err + } + + ldapEnabled, ldapErr := loginUsingLdap(query) + if ldapEnabled { + if ldapErr == nil || ldapErr != ErrInvalidCredentials { + return ldapErr } + + err = ldapErr + } + + if err == ErrInvalidCredentials { + saveInvalidLoginAttempt(query) + } + + if err == m.ErrUserNotFound { + return ErrInvalidCredentials } return err } - -func loginUsingGrafanaDB(query *LoginUserQuery) error { - userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} - - if err := bus.Dispatch(&userQuery); err != nil { - if err == m.ErrUserNotFound { - return ErrInvalidCredentials - } - return err - } - - user := userQuery.Result - - passwordHashed := util.EncodePassword(query.Password, user.Salt) - if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(user.Password)) != 1 { - return ErrInvalidCredentials - } - - query.User = user - return nil -} diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go new file mode 100644 index 00000000000..59d3c8f2b33 --- /dev/null +++ b/pkg/login/auth_test.go @@ -0,0 +1,214 @@ +package login + +import ( + "errors" + "testing" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAuthenticateUser(t *testing.T) { + Convey("Authenticate user", t, func() { + authScenario("When a user authenticates having too many login attempts", func(sc *authScenarioContext) { + mockLoginAttemptValidation(ErrTooManyLoginAttempts, sc) + mockLoginUsingGrafanaDB(nil, sc) + mockLoginUsingLdap(true, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeFalse) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate with valid credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(nil, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, nil) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate and unexpected error occurs", func(sc *authScenarioContext) { + customErr := errors.New("custom") + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(customErr, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, customErr) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and ldap disabled", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(false, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and invalid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) + }) + }) + + authScenario("When a non-existing grafana user authenticate and valid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldBeNil) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and ldap returns unexpected error", func(sc *authScenarioContext) { + customErr := errors.New("custom") + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, customErr, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, customErr) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate with invalid credentials and invalid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(ErrInvalidCredentials, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) + }) + }) + }) +} + +type authScenarioContext struct { + loginUserQuery *LoginUserQuery + grafanaLoginWasCalled bool + ldapLoginWasCalled bool + loginAttemptValidationWasCalled bool + saveInvalidLoginAttemptWasCalled bool +} + +type authScenarioFunc func(sc *authScenarioContext) + +func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { + loginUsingGrafanaDB = func(query *LoginUserQuery) error { + sc.grafanaLoginWasCalled = true + return err + } +} + +func mockLoginUsingLdap(enabled bool, err error, sc *authScenarioContext) { + loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + sc.ldapLoginWasCalled = true + return enabled, err + } +} + +func mockLoginAttemptValidation(err error, sc *authScenarioContext) { + validateLoginAttempts = func(username string) error { + sc.loginAttemptValidationWasCalled = true + return err + } +} + +func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { + saveInvalidLoginAttempt = func(query *LoginUserQuery) { + sc.saveInvalidLoginAttemptWasCalled = true + } +} + +func authScenario(desc string, fn authScenarioFunc) { + Convey(desc, func() { + origLoginUsingGrafanaDB := loginUsingGrafanaDB + origLoginUsingLdap := loginUsingLdap + origValidateLoginAttempts := validateLoginAttempts + origSaveInvalidLoginAttempt := saveInvalidLoginAttempt + + sc := &authScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + } + + defer func() { + loginUsingGrafanaDB = origLoginUsingGrafanaDB + loginUsingLdap = origLoginUsingLdap + validateLoginAttempts = origValidateLoginAttempts + saveInvalidLoginAttempt = origSaveInvalidLoginAttempt + }() + + fn(sc) + }) +} diff --git a/pkg/login/brute_force_login_protection.go b/pkg/login/brute_force_login_protection.go new file mode 100644 index 00000000000..2ea93979c7a --- /dev/null +++ b/pkg/login/brute_force_login_protection.go @@ -0,0 +1,48 @@ +package login + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + maxInvalidLoginAttempts int64 = 5 + loginAttemptsWindow time.Duration = time.Minute * 5 +) + +var validateLoginAttempts = func(username string) error { + if setting.DisableBruteForceLoginProtection { + return nil + } + + loginAttemptCountQuery := m.GetUserLoginAttemptCountQuery{ + Username: username, + Since: time.Now().Add(-loginAttemptsWindow), + } + + if err := bus.Dispatch(&loginAttemptCountQuery); err != nil { + return err + } + + if loginAttemptCountQuery.Result >= maxInvalidLoginAttempts { + return ErrTooManyLoginAttempts + } + + return nil +} + +var saveInvalidLoginAttempt = func(query *LoginUserQuery) { + if setting.DisableBruteForceLoginProtection { + return + } + + loginAttemptCommand := m.CreateLoginAttemptCommand{ + Username: query.Username, + IpAddress: query.IpAddress, + } + + bus.Dispatch(&loginAttemptCommand) +} diff --git a/pkg/login/brute_force_login_protection_test.go b/pkg/login/brute_force_login_protection_test.go new file mode 100644 index 00000000000..5375134ba88 --- /dev/null +++ b/pkg/login/brute_force_login_protection_test.go @@ -0,0 +1,125 @@ +package login + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLoginAttemptsValidation(t *testing.T) { + Convey("Validate login attempts", t, func() { + Convey("Given brute force login protection enabled", func() { + setting.DisableBruteForceLoginProtection = false + + Convey("When user login attempt count equals max-1 ", func() { + withLoginAttempts(maxInvalidLoginAttempts - 1) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count equals max ", func() { + withLoginAttempts(maxInvalidLoginAttempts) + err := validateLoginAttempts("user") + + Convey("it should result in too many login attempts error", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + }) + }) + + Convey("When user login attempt count is greater than max ", func() { + withLoginAttempts(maxInvalidLoginAttempts + 5) + err := validateLoginAttempts("user") + + Convey("it should result in too many login attempts error", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + }) + }) + + Convey("When saving invalid login attempt", func() { + defer bus.ClearBusHandlers() + createLoginAttemptCmd := &m.CreateLoginAttemptCommand{} + + bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { + createLoginAttemptCmd = cmd + return nil + }) + + saveInvalidLoginAttempt(&LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }) + + Convey("it should dispatch command", func() { + So(createLoginAttemptCmd, ShouldNotBeNil) + So(createLoginAttemptCmd.Username, ShouldEqual, "user") + So(createLoginAttemptCmd.IpAddress, ShouldEqual, "192.168.1.1:56433") + }) + }) + }) + + Convey("Given brute force login protection disabled", func() { + setting.DisableBruteForceLoginProtection = true + + Convey("When user login attempt count equals max-1 ", func() { + withLoginAttempts(maxInvalidLoginAttempts - 1) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count equals max ", func() { + withLoginAttempts(maxInvalidLoginAttempts) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count is greater than max ", func() { + withLoginAttempts(maxInvalidLoginAttempts + 5) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When saving invalid login attempt", func() { + defer bus.ClearBusHandlers() + createLoginAttemptCmd := (*m.CreateLoginAttemptCommand)(nil) + + bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { + createLoginAttemptCmd = cmd + return nil + }) + + saveInvalidLoginAttempt(&LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }) + + Convey("it should not dispatch command", func() { + So(createLoginAttemptCmd, ShouldBeNil) + }) + }) + }) + }) +} + +func withLoginAttempts(loginAttempts int64) { + bus.AddHandler("test", func(query *m.GetUserLoginAttemptCountQuery) error { + query.Result = loginAttempts + return nil + }) +} diff --git a/pkg/login/grafana_login.go b/pkg/login/grafana_login.go new file mode 100644 index 00000000000..677ba776e4f --- /dev/null +++ b/pkg/login/grafana_login.go @@ -0,0 +1,35 @@ +package login + +import ( + "crypto/subtle" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +var validatePassword = func(providedPassword string, userPassword string, userSalt string) error { + passwordHashed := util.EncodePassword(providedPassword, userSalt) + if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(userPassword)) != 1 { + return ErrInvalidCredentials + } + + return nil +} + +var loginUsingGrafanaDB = func(query *LoginUserQuery) error { + userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} + + if err := bus.Dispatch(&userQuery); err != nil { + return err + } + + user := userQuery.Result + + if err := validatePassword(query.Password, user.Password, user.Salt); err != nil { + return err + } + + query.User = user + return nil +} diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go new file mode 100644 index 00000000000..88e52224113 --- /dev/null +++ b/pkg/login/grafana_login_test.go @@ -0,0 +1,139 @@ +package login + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGrafanaLogin(t *testing.T) { + Convey("Login using Grafana DB", t, func() { + grafanaLoginScenario("When login with non-existing user", func(sc *grafanaLoginScenarioContext) { + sc.withNonExistingUser() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should result in user not found error", func() { + So(err, ShouldEqual, m.ErrUserNotFound) + }) + + Convey("it should not call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeFalse) + }) + + Convey("it should not pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldBeNil) + }) + }) + + grafanaLoginScenario("When login with invalid credentials", func(sc *grafanaLoginScenarioContext) { + sc.withInvalidPassword() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should result in invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeTrue) + }) + + Convey("it should not pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldBeNil) + }) + }) + + grafanaLoginScenario("When login with valid credentials", func(sc *grafanaLoginScenarioContext) { + sc.withValidCredentials() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeTrue) + }) + + Convey("it should pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldNotBeNil) + So(sc.loginUserQuery.User.Login, ShouldEqual, sc.loginUserQuery.Username) + So(sc.loginUserQuery.User.Password, ShouldEqual, sc.loginUserQuery.Password) + }) + }) + }) +} + +type grafanaLoginScenarioContext struct { + loginUserQuery *LoginUserQuery + validatePasswordCalled bool +} + +type grafanaLoginScenarioFunc func(c *grafanaLoginScenarioContext) + +func grafanaLoginScenario(desc string, fn grafanaLoginScenarioFunc) { + Convey(desc, func() { + origValidatePassword := validatePassword + + sc := &grafanaLoginScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + validatePasswordCalled: false, + } + + defer func() { + validatePassword = origValidatePassword + }() + + fn(sc) + }) +} + +func mockPasswordValidation(valid bool, sc *grafanaLoginScenarioContext) { + validatePassword = func(providedPassword string, userPassword string, userSalt string) error { + sc.validatePasswordCalled = true + + if !valid { + return ErrInvalidCredentials + } + + return nil + } +} + +func (sc *grafanaLoginScenarioContext) getUserByLoginQueryReturns(user *m.User) { + bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { + if user == nil { + return m.ErrUserNotFound + } + + query.Result = user + return nil + }) +} + +func (sc *grafanaLoginScenarioContext) withValidCredentials() { + sc.getUserByLoginQueryReturns(&m.User{ + Id: 1, + Login: sc.loginUserQuery.Username, + Password: sc.loginUserQuery.Password, + Salt: "salt", + }) + mockPasswordValidation(true, sc) +} + +func (sc *grafanaLoginScenarioContext) withNonExistingUser() { + sc.getUserByLoginQueryReturns(nil) +} + +func (sc *grafanaLoginScenarioContext) withInvalidPassword() { + sc.getUserByLoginQueryReturns(&m.User{ + Password: sc.loginUserQuery.Password, + Salt: "salt", + }) + mockPasswordValidation(false, sc) +} diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go new file mode 100644 index 00000000000..b74b69db036 --- /dev/null +++ b/pkg/login/ldap_login.go @@ -0,0 +1,21 @@ +package login + +import ( + "github.com/grafana/grafana/pkg/setting" +) + +var loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + if !setting.LdapEnabled { + return false, nil + } + + for _, server := range LdapCfg.Servers { + author := NewLdapAuthenticator(server) + err := author.Login(query) + if err == nil || err != ErrInvalidCredentials { + return true, err + } + } + + return true, ErrInvalidCredentials +} diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go new file mode 100644 index 00000000000..6af125566e8 --- /dev/null +++ b/pkg/login/ldap_login_test.go @@ -0,0 +1,172 @@ +package login + +import ( + "testing" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLdapLogin(t *testing.T) { + Convey("Login using ldap", t, func() { + Convey("Given ldap enabled and a server configured", func() { + setting.LdapEnabled = true + LdapCfg.Servers = append(LdapCfg.Servers, + &LdapServerConf{ + Host: "", + }) + + ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(false) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) + }) + }) + + ldapLoginScenario("When login with valid credentials", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(true) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) + }) + }) + }) + + Convey("Given ldap enabled and no server configured", func() { + setting.LdapEnabled = true + LdapCfg.Servers = make([]*LdapServerConf, 0) + + ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(true) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should not call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) + }) + }) + }) + + Convey("Given ldap disabled", func() { + setting.LdapEnabled = false + + ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(false) + enabled, err := loginUsingLdap(&LoginUserQuery{ + Username: "user", + Password: "pwd", + }) + + Convey("it should return false", func() { + So(enabled, ShouldBeFalse) + }) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should not call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) + }) + }) + }) + }) +} + +func mockLdapAuthenticator(valid bool) *mockLdapAuther { + mock := &mockLdapAuther{ + validLogin: valid, + } + + NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther { + return mock + } + + return mock +} + +type mockLdapAuther struct { + validLogin bool + loginCalled bool +} + +func (a *mockLdapAuther) Login(query *LoginUserQuery) error { + a.loginCalled = true + + if !a.validLogin { + return ErrInvalidCredentials + } + + return nil +} + +func (a *mockLdapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error { + return nil +} + +func (a *mockLdapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) { + return nil, nil +} + +func (a *mockLdapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { + return nil +} + +type ldapLoginScenarioContext struct { + loginUserQuery *LoginUserQuery + ldapAuthenticatorMock *mockLdapAuther +} + +type ldapLoginScenarioFunc func(c *ldapLoginScenarioContext) + +func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { + Convey(desc, func() { + origNewLdapAuthenticator := NewLdapAuthenticator + + sc := &ldapLoginScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + ldapAuthenticatorMock: &mockLdapAuther{}, + } + + defer func() { + NewLdapAuthenticator = origNewLdapAuthenticator + }() + + fn(sc) + }) +} + +func (sc *ldapLoginScenarioContext) withLoginResult(valid bool) { + sc.ldapAuthenticatorMock = mockLdapAuthenticator(valid) +} diff --git a/pkg/login/settings.go b/pkg/login/ldap_settings.go similarity index 100% rename from pkg/login/settings.go rename to pkg/login/ldap_settings.go diff --git a/pkg/models/login_attempt.go b/pkg/models/login_attempt.go new file mode 100644 index 00000000000..e4391927702 --- /dev/null +++ b/pkg/models/login_attempt.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" +) + +type LoginAttempt struct { + Id int64 + Username string + IpAddress string + Created time.Time +} + +// --------------------- +// COMMANDS + +type CreateLoginAttemptCommand struct { + Username string + IpAddress string + + Result LoginAttempt +} + +type DeleteOldLoginAttemptsCommand struct { + OlderThan time.Time + DeletedRows int64 +} + +// --------------------- +// QUERIES + +type GetUserLoginAttemptCountQuery struct { + Username string + Since time.Time + Result int64 +} diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index 6e5e7684100..f9dcfce51b7 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -46,6 +46,7 @@ func (service *CleanUpService) start(ctx context.Context) error { service.cleanUpTmpFiles() service.deleteExpiredSnapshots() service.deleteExpiredDashboardVersions() + service.deleteOldLoginAttempts() case <-ctx.Done(): return ctx.Err() } @@ -88,3 +89,18 @@ func (service *CleanUpService) deleteExpiredSnapshots() { func (service *CleanUpService) deleteExpiredDashboardVersions() { bus.Dispatch(&m.DeleteExpiredVersionsCommand{}) } + +func (service *CleanUpService) deleteOldLoginAttempts() { + if setting.DisableBruteForceLoginProtection { + return + } + + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: time.Now().Add(time.Minute * -10), + } + if err := bus.Dispatch(&cmd); err != nil { + service.log.Error("Problem deleting expired login attempts", "error", err.Error()) + } else { + service.log.Debug("Deleted expired login attempts", "rows affected", cmd.DeletedRows) + } +} diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go new file mode 100644 index 00000000000..805d726df48 --- /dev/null +++ b/pkg/services/sqlstore/login_attempt.go @@ -0,0 +1,91 @@ +package sqlstore + +import ( + "strconv" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +var getTimeNow = time.Now + +func init() { + bus.AddHandler("sql", CreateLoginAttempt) + bus.AddHandler("sql", DeleteOldLoginAttempts) + bus.AddHandler("sql", GetUserLoginAttemptCount) +} + +func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { + return inTransaction(func(sess *DBSession) error { + loginAttempt := m.LoginAttempt{ + Username: cmd.Username, + IpAddress: cmd.IpAddress, + Created: getTimeNow(), + } + + if _, err := sess.Insert(&loginAttempt); err != nil { + return err + } + + cmd.Result = loginAttempt + + return nil + }) +} + +func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error { + return inTransaction(func(sess *DBSession) error { + var maxId int64 + sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?") + result, err := sess.Query(sql, cmd.OlderThan) + + if err != nil { + return err + } + + maxId = toInt64(result[0]["id"]) + + if maxId == 0 { + return nil + } + + sql = "DELETE FROM login_attempt WHERE id <= ?" + + if result, err := sess.Exec(sql, maxId); err != nil { + return err + } else if cmd.DeletedRows, err = result.RowsAffected(); err != nil { + return err + } + + return nil + }) +} + +func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error { + loginAttempt := new(m.LoginAttempt) + total, err := x. + Where("username = ?", query.Username). + And("created >="+dialect.DateTimeFunc("?"), query.Since). + Count(loginAttempt) + + if err != nil { + return err + } + + query.Result = total + return nil +} + +func toInt64(i interface{}) int64 { + switch i.(type) { + case []byte: + n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64) + return n + case int: + return int64(i.(int)) + case int64: + return i.(int64) + } + return 0 +} diff --git a/pkg/services/sqlstore/login_attempt_test.go b/pkg/services/sqlstore/login_attempt_test.go new file mode 100644 index 00000000000..8008e2d8a62 --- /dev/null +++ b/pkg/services/sqlstore/login_attempt_test.go @@ -0,0 +1,125 @@ +package sqlstore + +import ( + "testing" + "time" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func mockTime(mock time.Time) time.Time { + getTimeNow = func() time.Time { return mock } + return mock +} + +func TestLoginAttempts(t *testing.T) { + Convey("Testing Login Attempts DB Access", t, func() { + InitTestDB(t) + + user := "user" + beginningOfTime := mockTime(time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local)) + + err := CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + timePlusOneMinute := mockTime(beginningOfTime.Add(time.Minute * 1)) + + err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + timePlusTwoMinutes := mockTime(beginningOfTime.Add(time.Minute * 2)) + + err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + Convey("Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusTwoMinutes.Add(time.Second * 1), + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 0) + }) + + Convey("Should return the total count of login attempts since beginning of time", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: beginningOfTime, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 3) + }) + + Convey("Should return the total count of login attempts since beginning of time + 1min", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusOneMinute, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 2) + }) + + Convey("Should return the total count of login attempts since beginning of time + 2min", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusTwoMinutes, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 1) + }) + + Convey("Should return deleted rows older than beginning of time", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: beginningOfTime, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 0) + }) + + Convey("Should return deleted rows older than beginning of time + 1min", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusOneMinute, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 1) + }) + + Convey("Should return deleted rows older than beginning of time + 2min", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusTwoMinutes, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 2) + }) + + Convey("Should return deleted rows older than beginning of time + 2min and 1s", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusTwoMinutes.Add(time.Second * 1), + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 3) + }) + }) +} diff --git a/pkg/services/sqlstore/migrations/login_attempt_mig.go b/pkg/services/sqlstore/migrations/login_attempt_mig.go new file mode 100644 index 00000000000..e576ccd1a50 --- /dev/null +++ b/pkg/services/sqlstore/migrations/login_attempt_mig.go @@ -0,0 +1,23 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addLoginAttemptMigrations(mg *Migrator) { + loginAttemptV1 := Table{ + Name: "login_attempt", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"username"}}, + }, + } + + // create table + mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1)) + // add indices + mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 8e9268779ef..282f98e7318 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -29,6 +29,7 @@ func AddMigrations(mg *Migrator) { addTeamMigrations(mg) addDashboardAclMigrations(mg) addTagMigration(mg) + addLoginAttemptMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 651405921d9..064b5981063 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -19,6 +19,7 @@ type Dialect interface { LikeStr() string Default(col *Column) string BooleanStr(bool) string + DateTimeFunc(string) string CreateIndexSql(tableName string, index *Index) string CreateTableSql(table *Table) string @@ -78,6 +79,10 @@ func (b *BaseDialect) Default(col *Column) string { return col.Default } +func (db *BaseDialect) DateTimeFunc(value string) string { + return value +} + func (b *BaseDialect) CreateTableSql(table *Table) string { var sql string sql = "CREATE TABLE IF NOT EXISTS " diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index fe1e781c8df..1a31cee4f5e 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -36,6 +36,10 @@ func (db *Sqlite3) BooleanStr(value bool) string { return "0" } +func (db *Sqlite3) DateTimeFunc(value string) string { + return "datetime(" + value + ")" +} + func (db *Sqlite3) SqlType(c *Column) string { switch c.Type { case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time: diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 4aa2ec27216..a33872ed687 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -12,7 +12,7 @@ type TestDB struct { } var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} -var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} +var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci&loc=Local"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} func CleanDB(x *xorm.Engine) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index d236446eb71..6ce80a69957 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -75,13 +75,14 @@ var ( EnforceDomain bool // Security settings. - SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string - DisableGravatar bool - EmailCodeValidMinutes int - DataProxyWhiteList map[string]bool + SecretKey string + LogInRememberDays int + CookieUserName string + CookieRememberName string + DisableGravatar bool + EmailCodeValidMinutes int + DataProxyWhiteList map[string]bool + DisableBruteForceLoginProtection bool // Snapshots ExternalSnapshotUrl string @@ -514,6 +515,7 @@ func NewConfigContext(args *CommandLineArgs) error { CookieUserName = security.Key("cookie_username").String() CookieRememberName = security.Key("cookie_remember_name").String() DisableGravatar = security.Key("disable_gravatar").MustBool(true) + DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) // read snapshots settings snapshots := Cfg.Section("snapshots") From a17dbf9af82ad8e0132826f1e24e01a39b0888d1 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 26 Jan 2018 10:50:42 +0100 Subject: [PATCH 06/24] changelog: be more explicit about backwards compatibility --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c17d684b8..5e205509ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha ## New Dashboard Grid -The new grid engine is major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backwards compatible. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. +The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. From 0c6a90db089e7934f3430bdcedd47cae7104c460 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 26 Jan 2018 11:16:09 +0100 Subject: [PATCH 07/24] changelog: move all 4.7 changes into 5.0 --- CHANGELOG.md | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e205509ebc..d7dd13dba98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5. -### New Features +### New Major Features - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) - **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. - **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). @@ -10,6 +10,14 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha - **UX**: Major update to page header and navigation - **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) +## Breaking changes + +* **[dashboard.json]** have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). +Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. +From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. + +* **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) + ## New Dashboard Grid The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. @@ -18,23 +26,6 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: ## New Features * **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM) - -## Minor -* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) -* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) - -# 4.7.0 (unreleased / v4.7.x branch) - -## Breaking changes - -`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). - -Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. -From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. - -The pagerduty notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) - -## New Features * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm) * **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda) @@ -45,8 +36,11 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) * **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) * **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) +* **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616) ## Minor +* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) +* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend) * **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell) * **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda) @@ -59,16 +53,15 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details * **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada) * **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell) -## Tech -* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) - - ## Fixes * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) * **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +## Tech +* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) + # 4.6.3 (2017-12-14) ## Fixes From eefcb3080abacfd6d2a773e7591d7cd762fb04a5 Mon Sep 17 00:00:00 2001 From: hannes Date: Fri, 26 Jan 2018 14:10:17 +0100 Subject: [PATCH 08/24] fix typo in parameter. (#10613) * options.scopedVars was called without 'd', so a undefined was passed to the function convertDimensionFormat() --- public/app/plugins/datasource/cloudwatch/datasource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 997a84a4a99..facddd2e18e 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -36,7 +36,7 @@ export default class CloudWatchDatasource { item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars); item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars); item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars); - item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopeVars); + item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting return _.extend( From d85b9c28c12cb8c18f1f595caeec65eb63c7f5e8 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Fri, 26 Jan 2018 14:30:07 +0100 Subject: [PATCH 09/24] Locks down prometheus1 to v1.8.2 in live-test. --- docker/blocks/prometheus/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/blocks/prometheus/Dockerfile b/docker/blocks/prometheus/Dockerfile index 1ad28f524ff..2098e6527d3 100644 --- a/docker/blocks/prometheus/Dockerfile +++ b/docker/blocks/prometheus/Dockerfile @@ -1,3 +1,3 @@ -FROM prom/prometheus +FROM prom/prometheus:v1.8.2 ADD prometheus.yml /etc/prometheus/ ADD alert.rules /etc/prometheus/ From 33beacff0a255ebd7f9153b4b44e6e93ccb28017 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 26 Jan 2018 14:34:47 +0100 Subject: [PATCH 10/24] tech: upgrade to golang 1.9.3 --- circle.yml | 2 +- scripts/build/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/circle.yml b/circle.yml index 4eb600bfde3..bf013e3f5b1 100644 --- a/circle.yml +++ b/circle.yml @@ -9,7 +9,7 @@ machine: GOPATH: "/home/ubuntu/.go_workspace" ORG_PATH: "github.com/grafana" REPO_PATH: "${ORG_PATH}/grafana" - GODIST: "go1.9.2.linux-amd64.tar.gz" + GODIST: "go1.9.3.linux-amd64.tar.gz" post: - mkdir -p ~/download - mkdir -p ~/docker diff --git a/scripts/build/Dockerfile b/scripts/build/Dockerfile index 6e48b42c8e8..8fde9eb1dcd 100644 --- a/scripts/build/Dockerfile +++ b/scripts/build/Dockerfile @@ -21,7 +21,7 @@ RUN gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A170311380 RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - && \ yum install -y nodejs --nogpgcheck -ENV GOLANG_VERSION 1.9.2 +ENV GOLANG_VERSION 1.9.3 RUN wget https://dl.yarnpkg.com/rpm/yarn.repo -O /etc/yum.repos.d/yarn.repo && \ yum install -y yarn --nogpgcheck && \ From 08822fbdcacb0a0461379e42150b20e09c6be357 Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 14:46:52 +0100 Subject: [PATCH 11/24] added media break for md and sm --- public/sass/components/_search.scss | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index d90943f4b60..f92f4dd1f27 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -248,6 +248,39 @@ background: $panel-bg; } +@include media-breakpoint-down(md) { + .search-dropdown { + flex-direction: column; + } + + .search-dropdown__col_1 { + height: 100%; + } + + .search-dropdown__col_2 { + max-width: 700px; + flex-direction: row; + margin: 0; + justify-content: space-between; + height: 260px; + } + + .search-filter-box { + margin: 0; + } +} + +@include media-breakpoint-down(sm) { + .search-dropdown__col_2 { + flex-direction: column; + height: 100%; + } + + .search-filter-box { + margin-bottom: 1.5rem; + } +} + @include media-breakpoint-down(xs) { .search-container { left: 0; From 2f891726c325fadde967e2ea232ee326b3e382a2 Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 14:57:55 +0100 Subject: [PATCH 12/24] fix for sm --- public/sass/components/_search.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index f92f4dd1f27..9ae277ce0a5 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -273,7 +273,8 @@ @include media-breakpoint-down(sm) { .search-dropdown__col_2 { flex-direction: column; - height: 100%; + height: 80%; + justify-content: flex-start; } .search-filter-box { From e6c19eb8e9453ca88fb5ef42f9340831a36c8024 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 26 Jan 2018 17:05:05 +0300 Subject: [PATCH 13/24] graphite: fix nested alerting queries (#10633) --- .../datasource/graphite/graphite_query.ts | 22 ++++++++- .../graphite/specs/graphite_query.jest.ts | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts diff --git a/public/app/plugins/datasource/graphite/graphite_query.ts b/public/app/plugins/datasource/graphite/graphite_query.ts index 10e0144e3ff..baa58237708 100644 --- a/public/app/plugins/datasource/graphite/graphite_query.ts +++ b/public/app/plugins/datasource/graphite/graphite_query.ts @@ -181,6 +181,22 @@ export default class GraphiteQuery { var nestedSeriesRefRegex = /\#([A-Z])/g; var targetWithNestedQueries = target.target; + // Use ref count to track circular references + function countTargetRefs(targetsByRefId, refId) { + let refCount = 0; + _.each(targetsByRefId, (t, id) => { + if (id !== refId) { + let match = nestedSeriesRefRegex.exec(t.target); + let count = match && match.length ? match.length - 1 : 0; + refCount += count; + } + }); + targetsByRefId[refId].refCount = refCount; + } + _.each(targetsByRefId, (t, id) => { + countTargetRefs(targetsByRefId, id); + }); + // Keep interpolating until there are no query references // The reason for the loop is that the referenced query might contain another reference to another query while (targetWithNestedQueries.match(nestedSeriesRefRegex)) { @@ -191,7 +207,11 @@ export default class GraphiteQuery { } // no circular references - delete targetsByRefId[g1]; + if (t.refCount === 0) { + delete targetsByRefId[g1]; + } + t.refCount--; + return t.target; }); diff --git a/public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts b/public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts new file mode 100644 index 00000000000..d54caae05f8 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts @@ -0,0 +1,47 @@ +import gfunc from '../gfunc'; +import GraphiteQuery from '../graphite_query'; + +describe('Graphite query model', () => { + let ctx: any = { + datasource: { + getFuncDef: gfunc.getFuncDef, + getFuncDefs: jest.fn().mockReturnValue(Promise.resolve(gfunc.getFuncDefs('1.0'))), + waitForFuncDefsLoaded: jest.fn().mockReturnValue(Promise.resolve(null)), + createFuncInstance: gfunc.createFuncInstance, + }, + templateSrv: {}, + targets: [], + }; + + beforeEach(() => { + ctx.target = { refId: 'A', target: 'scaleToSeconds(#A, 60)' }; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + describe('when updating targets with nested queries', () => { + beforeEach(() => { + ctx.target = { refId: 'D', target: 'asPercent(#A, #C)' }; + ctx.targets = [ + { refId: 'A', target: 'first.query.count' }, + { refId: 'B', target: 'second.query.count' }, + { refId: 'C', target: 'diffSeries(#A, #B)' }, + { refId: 'D', target: 'asPercent(#A, #C)' }, + ]; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + it('targetFull should include nested queries', () => { + ctx.queryModel.updateRenderedTarget(ctx.target, ctx.targets); + const targetFullExpected = 'asPercent(first.query.count, diffSeries(first.query.count, second.query.count))'; + expect(ctx.queryModel.target.targetFull).toBe(targetFullExpected); + }); + + it('should not hang on circular references', () => { + ctx.target.target = 'asPercent(#A, #B)'; + ctx.targets = [{ refId: 'A', target: 'asPercent(#B, #C)' }, { refId: 'B', target: 'asPercent(#A, #C)' }]; + ctx.queryModel.updateRenderedTarget(ctx.target, ctx.targets); + // Just ensure updateRenderedTarget() is completed and doesn't hang + expect(ctx.queryModel.target.targetFull).toBeDefined(); + }); + }); +}); From ffff75b01a20ee8692c3462334fd814a89d8405b Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 16:59:16 +0100 Subject: [PATCH 14/24] reverted media queries --- public/sass/components/_search.scss | 63 +++++++++++++---------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index 9ae277ce0a5..98616571ed8 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -10,7 +10,7 @@ } .search-container { - left: $side-menu-width; + left: 0; top: 0; right: 0; bottom: 0; @@ -58,7 +58,7 @@ .search-dropdown { display: flex; - flex-direction: row; + flex-direction: column; height: calc(100% - #{$navbarHeight}); } @@ -74,7 +74,7 @@ flex-grow: 1; height: 100%; padding-top: 16px; - display: flex; + display: none; flex-direction: column; align-items: flex-start; } @@ -85,7 +85,6 @@ padding: $spacer*1.5; min-width: 340px; margin-bottom: $spacer * 1.5; - margin-left: $spacer * 1.5; } .search-filter-box__header { @@ -215,7 +214,8 @@ } .search-item__tags { - padding: 10px; + display: none; + //padding: 10px; } .search-item__actions { @@ -248,50 +248,45 @@ background: $panel-bg; } -@include media-breakpoint-down(md) { - .search-dropdown { - flex-direction: column; +@include media-breakpoint-up(sm) { + .search-container { + left: $side-menu-width; + } + + .search-dropdown__col_2 { + display: flex; + margin-bottom: 1rem; + } +} + +@include media-breakpoint-up(md) { + .search-dropdown__col_2 { + flex-direction: row; + justify-content: space-between; + max-width: 700px; + height: 260px; } .search-dropdown__col_1 { height: 100%; } - .search-dropdown__col_2 { - max-width: 700px; - flex-direction: row; - margin: 0; - justify-content: space-between; - height: 260px; - } - .search-filter-box { margin: 0; } } -@include media-breakpoint-down(sm) { +@include media-breakpoint-up(lg) { + .search-dropdown { + flex-direction: row; + } + .search-dropdown__col_2 { flex-direction: column; - height: 80%; - justify-content: flex-start; } .search-filter-box { - margin-bottom: 1.5rem; - } -} - -@include media-breakpoint-down(xs) { - .search-container { - left: 0; - } - - .search-dropdown__col_2 { - display: none; - } - - .search-item__tags { - display: none; + margin-left: $spacer * 1.5; + margin-bottom: $spacer * 1.5; } } From 5f81f401e36a9a87a61454a83d930a71ca1953cd Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 17:25:12 +0100 Subject: [PATCH 15/24] replaced input with gf-form-dropdown --- .../plugins/panel/table/column_options.html | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/public/app/plugins/panel/table/column_options.html b/public/app/plugins/panel/table/column_options.html index 996f10960f9..c5c7b8dbab4 100644 --- a/public/app/plugins/panel/table/column_options.html +++ b/public/app/plugins/panel/table/column_options.html @@ -1,17 +1,16 @@ -
-
@@ -20,7 +19,9 @@
- +
@@ -39,18 +40,20 @@
-
+
-
- -
+ +
- +
- +
@@ -60,16 +63,20 @@
- +
-
+
Thresholds
- - + +
@@ -102,21 +109,23 @@

Specify an URL (relative or absolute)

- Use special variables to specify cell values:
- $__cell refers to current cell value
+ Use special variables to specify cell values: +
+ $__cell refers to current cell value +
$__cell_n refers to Nth column value in current row. Column indexes are started from 0. For instance, - $__cell_1 refers to second column's value. + $__cell_1 refers to second column's value.
- +

Specify text for link tooltip.

- This title appears when user hovers pointer over the cell with link. - Use the same variables as for URL. + This title appears when user hovers pointer over the cell with link. Use the same variables as for URL.
From cd2161e7966fd05daed843ee5e45cac78b335d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 08:38:23 +0100 Subject: [PATCH 16/24] Revert "Fix typeahead to avoid generating new backend request on each keypress. (#10596)" This reverts commit 475febd0048dcd5fc1a161556f0e909efec33405. --- public/app/core/components/query_part/query_part_editor.ts | 4 ++-- public/app/core/directives/metric_segment.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts index c78ca8d74e6..138da186238 100644 --- a/public/app/core/components/query_part/query_part_editor.ts +++ b/public/app/core/components/query_part/query_part_editor.ts @@ -123,11 +123,11 @@ export function queryPartEditorDirective($compile, templateSrv) { }); var typeahead = $input.data('typeahead'); - typeahead.lookup = _.debounce(function() { + typeahead.lookup = function() { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }, 500); + }; } $scope.showActionsMenu = function() { diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js index b11625227c7..2754f8d8c6e 100644 --- a/public/app/core/directives/metric_segment.js +++ b/public/app/core/directives/metric_segment.js @@ -129,11 +129,11 @@ function (_, $, coreModule) { $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher }); var typeahead = $input.data('typeahead'); - typeahead.lookup = _.debounce(function() { + typeahead.lookup = function () { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }, 500); + }; $button.keydown(function(evt) { // trigger typeahead on down arrow or enter key From 2782ad09553d79705152361e72074a8cd3df8182 Mon Sep 17 00:00:00 2001 From: bergquist Date: Mon, 29 Jan 2018 09:52:35 +0100 Subject: [PATCH 17/24] plugins: only set error if errorstring is not empty --- .../datasource/wrapper/datasource_plugin_wrapper.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go index 91511f96685..f9c9f9d3b16 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go @@ -69,10 +69,13 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou for _, r := range pbres.Results { qr := &tsdb.QueryResult{ - RefId: r.RefId, - Series: []*tsdb.TimeSeries{}, - Error: errors.New(r.Error), - ErrorString: r.Error, + RefId: r.RefId, + Series: []*tsdb.TimeSeries{}, + } + + if r.Error != "" { + qr.Error = errors.New(r.Error) + qr.ErrorString = r.Error } for _, s := range r.GetSeries() { From b6ce16ebae60448930f8a9b72a0573da203dc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 10:41:06 +0100 Subject: [PATCH 18/24] ux: minor tweak of #10634 --- public/sass/components/_search.scss | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index 98616571ed8..700a4d878e2 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -38,13 +38,6 @@ background-color: $navbarButtonBackground; flex-grow: 10; } - - // .tag-filter { - // .Select-control { - // width: 300px; - // background-color: $navbarBackground; - // } - // } } .search-field-spacer { @@ -76,7 +69,6 @@ padding-top: 16px; display: none; flex-direction: column; - align-items: flex-start; } .search-filter-box { @@ -265,6 +257,7 @@ justify-content: space-between; max-width: 700px; height: 260px; + align-items: flex-start; } .search-dropdown__col_1 { From a0323e96fac3b6fdfb5298c52cfdd61a972fb8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 10:46:05 +0100 Subject: [PATCH 19/24] fix: tweak of PR #10635 --- public/app/plugins/panel/table/column_options.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/plugins/panel/table/column_options.html b/public/app/plugins/panel/table/column_options.html index c5c7b8dbab4..bebc05f0e53 100644 --- a/public/app/plugins/panel/table/column_options.html +++ b/public/app/plugins/panel/table/column_options.html @@ -42,8 +42,8 @@
- +
From b3ac85766ee345ade473db5c2dae3662afbb4585 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 29 Jan 2018 15:38:37 +0300 Subject: [PATCH 20/24] fix: show sidebar after mouse wheel scrolling (#10657) --- public/app/core/components/grafana_app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index c00b7b2181b..13c7f48de3e 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -167,6 +167,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop // mouse and keyboard is user activity body.mousemove(userActivityDetected); body.keydown(userActivityDetected); + // set useCapture = true to catch event here + document.addEventListener('wheel', userActivityDetected, true); // treat tab change as activity document.addEventListener('visibilitychange', userActivityDetected); From 04053ec56cae4948fc0e4d24b2dcfbdc574fd837 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 29 Jan 2018 16:33:41 +0300 Subject: [PATCH 21/24] fix: don't show manually hidden sidemenu after view mode toggle (#10659) --- public/app/core/components/grafana_app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 13c7f48de3e..31b6f5b8096 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -71,6 +71,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop body.toggleClass('sidemenu-open', sidemenuOpen); appEvents.on('toggle-sidemenu', () => { + sidemenuOpen = scope.contextSrv.sidemenu; body.toggleClass('sidemenu-open'); }); From 479658489ad7a215ee8f06f861b3a0374534c682 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 29 Jan 2018 17:17:27 +0300 Subject: [PATCH 22/24] fix: remove repeated rows when repeat was disabled. (#10653) --- public/app/features/dashboard/dashgrid/DashboardRow.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/dashgrid/DashboardRow.tsx index fad7c120f65..7a4d6cf8070 100644 --- a/public/app/features/dashboard/dashgrid/DashboardRow.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardRow.tsx @@ -27,6 +27,7 @@ export class DashboardRow extends React.Component { this.toggle = this.toggle.bind(this); this.openSettings = this.openSettings.bind(this); this.delete = this.delete.bind(this); + this.update = this.update.bind(this); } toggle() { @@ -37,13 +38,18 @@ export class DashboardRow extends React.Component { }); } + update() { + this.dashboard.processRepeats(); + this.forceUpdate(); + } + openSettings() { appEvents.emit('show-modal', { templateHtml: ``, modalClass: 'modal--narrow', model: { row: this.props.panel, - onUpdated: this.forceUpdate.bind(this), + onUpdated: this.update.bind(this), }, }); } From b1cf4cf01c291ffb4ed8d19eec54ba844714afb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 17:04:11 +0100 Subject: [PATCH 23/24] fix: InfluxDB Query Editor and selecting template variable in where clause caused issue, fixes #10402, fixes #10663 --- public/app/core/services/segment_srv.js | 2 +- public/app/plugins/datasource/influxdb/query_ctrl.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/core/services/segment_srv.js b/public/app/core/services/segment_srv.js index 6c126da1acd..71d0cbfe7a9 100644 --- a/public/app/core/services/segment_srv.js +++ b/public/app/core/services/segment_srv.js @@ -89,7 +89,7 @@ function (angular, _, coreModule) { if (addTemplateVars) { _.each(templateSrv.variables, function(variable) { if (variableTypeFilter === void 0 || variableTypeFilter === variable.type) { - segments.unshift(self.newSegment({ type: 'template', value: '$' + variable.name, expandable: true })); + segments.unshift(self.newSegment({ type: 'value', value: '$' + variable.name, expandable: true })); } }); } diff --git a/public/app/plugins/datasource/influxdb/query_ctrl.ts b/public/app/plugins/datasource/influxdb/query_ctrl.ts index c67b65334af..ce669c9f458 100644 --- a/public/app/plugins/datasource/influxdb/query_ctrl.ts +++ b/public/app/plugins/datasource/influxdb/query_ctrl.ts @@ -255,7 +255,7 @@ export class InfluxQueryCtrl extends QueryCtrl { for (let variable of this.templateSrv.variables) { segments.unshift( this.uiSegmentSrv.newSegment({ - type: 'template', + type: 'value', value: '/^$' + variable.name + '$/', expandable: true, }) From 0573545d5a4a12cd32917eb199990d401b2ea561 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Mon, 29 Jan 2018 17:06:16 +0100 Subject: [PATCH 24/24] ui: Fix Firefox align issue in dropdowns #10527 (#10662) --- public/sass/components/_dropdown.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/public/sass/components/_dropdown.scss b/public/sass/components/_dropdown.scss index 1bdda9a0b6e..d91a6d0a5a7 100644 --- a/public/sass/components/_dropdown.scss +++ b/public/sass/components/_dropdown.scss @@ -33,7 +33,7 @@ border-top: 4px solid $text-color-weak; border-right: 4px solid transparent; border-left: 4px solid transparent; - content: ""; + content: ''; } // Place the caret @@ -218,7 +218,7 @@ .caret { border-top: 0; border-bottom: 4px solid $black; - content: ""; + content: ''; } // Different positioning for bottom up menu .dropdown-menu { @@ -255,9 +255,9 @@ } // Caret to indicate there is a submenu -.dropdown-submenu > a::after { +.dropdown-submenu > a::before { display: block; - content: " "; + content: ' '; float: right; width: 0; height: 0; @@ -312,7 +312,7 @@ width: 2rem; display: inline-block; text-align: center; - content: "\f11c"; + content: '\f11c'; } }