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/CHANGELOG.md b/CHANGELOG.md index b1c17d684b8..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,31 +10,22 @@ 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 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. ## 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 diff --git a/Gopkg.lock b/Gopkg.lock index 0b9b74b2749..4d5f6ca0a46 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -4,8 +4,8 @@ [[projects]] name = "cloud.google.com/go" packages = ["compute/metadata"] - revision = "050b16d2314d5fc3d4c9a51e4cd5c7468e77f162" - version = "v0.17.0" + revision = "767c40d6a2e058483c25fa193e963a22da17236d" + version = "v0.18.0" [[projects]] name = "github.com/BurntSushi/toml" @@ -27,9 +27,39 @@ [[projects]] name = "github.com/aws/aws-sdk-go" - packages = ["aws","aws/awserr","aws/awsutil","aws/client","aws/client/metadata","aws/corehandlers","aws/credentials","aws/credentials/ec2rolecreds","aws/credentials/endpointcreds","aws/credentials/stscreds","aws/defaults","aws/ec2metadata","aws/endpoints","aws/request","aws/session","aws/signer/v4","internal/shareddefaults","private/protocol","private/protocol/ec2query","private/protocol/query","private/protocol/query/queryutil","private/protocol/rest","private/protocol/restxml","private/protocol/xml/xmlutil","service/cloudwatch","service/ec2","service/ec2/ec2iface","service/s3","service/sts"] - revision = "c13a879e75646fe750d21bcb05bf2cabea9791c1" - version = "v1.12.65" + packages = [ + "aws", + "aws/awserr", + "aws/awsutil", + "aws/client", + "aws/client/metadata", + "aws/corehandlers", + "aws/credentials", + "aws/credentials/ec2rolecreds", + "aws/credentials/endpointcreds", + "aws/credentials/stscreds", + "aws/defaults", + "aws/ec2metadata", + "aws/endpoints", + "aws/request", + "aws/session", + "aws/signer/v4", + "internal/shareddefaults", + "private/protocol", + "private/protocol/ec2query", + "private/protocol/query", + "private/protocol/query/queryutil", + "private/protocol/rest", + "private/protocol/restxml", + "private/protocol/xml/xmlutil", + "service/cloudwatch", + "service/ec2", + "service/ec2/ec2iface", + "service/s3", + "service/sts" + ] + revision = "decd990ddc5dcdf2f73309cbcab90d06b996ca28" + version = "v1.12.67" [[projects]] branch = "master" @@ -43,12 +73,6 @@ packages = ["quantile"] revision = "4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9" -[[projects]] - name = "github.com/bitly/go-simplejson" - packages = ["."] - revision = "aabad6e819789e569bd6aabf444c935aa9ba1e44" - version = "v0.5.0" - [[projects]] branch = "master" name = "github.com/bmizerany/assert" @@ -118,14 +142,19 @@ [[projects]] branch = "master" name = "github.com/go-macaron/session" - packages = [".","memcache","mysql","postgres","redis"] + packages = [ + ".", + "memcache", + "mysql", + "postgres", + "redis" + ] revision = "b8e286a0dba8f4999042d6b258daf51b31d08938" [[projects]] name = "github.com/go-sql-driver/mysql" packages = ["."] - revision = "a0583e0143b1624142adab07e0e97fe106d99561" - version = "v1.3" + revision = "2cc627ac8defc45d65066ae98f898166f580f9a4" [[projects]] name = "github.com/go-stack/stack" @@ -152,8 +181,14 @@ [[projects]] branch = "master" name = "github.com/golang/protobuf" - packages = ["proto","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"] - revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "c65a0412e71e8b9b3bfd22925720d23c0f054237" [[projects]] branch = "master" @@ -183,7 +218,7 @@ branch = "master" name = "github.com/hashicorp/go-hclog" packages = ["."] - revision = "ca137eb4b4389c9bc6f1a6d887f056bf16c00510" + revision = "5bcb0f17e36442247290887cc914a6e507afa5c4" [[projects]] name = "github.com/hashicorp/go-plugin" @@ -221,7 +256,10 @@ [[projects]] name = "github.com/klauspost/compress" - packages = ["flate","gzip"] + packages = [ + "flate", + "gzip" + ] revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf" version = "v1.2.1" @@ -252,8 +290,11 @@ [[projects]] branch = "master" name = "github.com/lib/pq" - packages = [".","oid"] - revision = "27ea5d92de30060e7121ddd543fe14e9a327e0cc" + packages = [ + ".", + "oid" + ] + revision = "61fe37aa2ee24fabcdbe5c4ac1d4ac566f88f345" [[projects]] name = "github.com/mattn/go-colorable" @@ -287,7 +328,11 @@ [[projects]] name = "github.com/opentracing/opentracing-go" - packages = [".","ext","log"] + packages = [ + ".", + "ext", + "log" + ] revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" version = "v1.0.2" @@ -299,7 +344,12 @@ [[projects]] name = "github.com/prometheus/client_golang" - packages = ["api","api/prometheus/v1","prometheus","prometheus/promhttp"] + packages = [ + "api", + "api/prometheus/v1", + "prometheus", + "prometheus/promhttp" + ] revision = "967789050ba94deca04a5e84cce8ad472ce313c1" version = "v0.9.0-pre1" @@ -312,14 +362,23 @@ [[projects]] branch = "master" name = "github.com/prometheus/common" - packages = ["expfmt","internal/bitbucket.org/ww/goautoneg","model"] + packages = [ + "expfmt", + "internal/bitbucket.org/ww/goautoneg", + "model" + ] revision = "89604d197083d4781071d3c65855d24ecfb0a563" [[projects]] branch = "master" name = "github.com/prometheus/procfs" - packages = [".","xfs"] - revision = "b15cd069a83443be3154b719d0cc9fe8117f09fb" + packages = [ + ".", + "internal/util", + "nfsd", + "xfs" + ] + revision = "85fadb6e89903ef7cca6f6a804474cd5ea85b6e1" [[projects]] branch = "master" @@ -335,19 +394,41 @@ [[projects]] name = "github.com/smartystreets/assertions" - packages = [".","internal/go-render/render","internal/oglematchers"] + packages = [ + ".", + "internal/go-render/render", + "internal/oglematchers" + ] revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea" version = "1.8.1" [[projects]] name = "github.com/smartystreets/goconvey" - packages = ["convey","convey/gotest","convey/reporting"] + packages = [ + "convey", + "convey/gotest", + "convey/reporting" + ] revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" version = "1.6.3" [[projects]] name = "github.com/uber/jaeger-client-go" - packages = [".","config","internal/baggage","internal/baggage/remote","internal/spanlog","log","rpcmetrics","thrift-gen/agent","thrift-gen/baggage","thrift-gen/jaeger","thrift-gen/sampling","thrift-gen/zipkincore","utils"] + packages = [ + ".", + "config", + "internal/baggage", + "internal/baggage/remote", + "internal/spanlog", + "log", + "rpcmetrics", + "thrift-gen/agent", + "thrift-gen/baggage", + "thrift-gen/jaeger", + "thrift-gen/sampling", + "thrift-gen/zipkincore", + "utils" + ] revision = "3ac96c6e679cb60a74589b0d0aa7c70a906183f7" version = "v2.11.2" @@ -359,7 +440,10 @@ [[projects]] name = "github.com/yudai/gojsondiff" - packages = [".","formatter"] + packages = [ + ".", + "formatter" + ] revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6" version = "1.0.0" @@ -373,18 +457,33 @@ branch = "master" name = "golang.org/x/crypto" packages = ["pbkdf2"] - revision = "13931e22f9e72ea58bb73048bc752b48c6d4d4ac" + revision = "3d37316aaa6bd9929127ac9a527abf408178ea7b" [[projects]] branch = "master" name = "golang.org/x/net" - packages = ["context","context/ctxhttp","http2","http2/hpack","idna","internal/timeseries","lex/httplex","trace"] + packages = [ + "context", + "context/ctxhttp", + "http2", + "http2/hpack", + "idna", + "internal/timeseries", + "lex/httplex", + "trace" + ] revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec" [[projects]] branch = "master" name = "golang.org/x/oauth2" - packages = [".","google","internal","jws","jwt"] + packages = [ + ".", + "google", + "internal", + "jws", + "jwt" + ] revision = "b28fcf2b08a19742b43084fb40ab78ac6c3d8067" [[projects]] @@ -397,17 +496,44 @@ branch = "master" name = "golang.org/x/sys" packages = ["unix"] - revision = "2c42eef0765b9837fbdab12011af7830f55f88f0" + revision = "af50095a40f9041b3b38960738837185c26e9419" [[projects]] branch = "master" name = "golang.org/x/text" - packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"] + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3" [[projects]] name = "google.golang.org/appengine" - packages = [".","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/modules","internal/remote_api","internal/urlfetch","urlfetch"] + packages = [ + ".", + "cloudsql", + "internal", + "internal/app_identity", + "internal/base", + "internal/datastore", + "internal/log", + "internal/modules", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" version = "v1.0.0" @@ -419,9 +545,34 @@ [[projects]] name = "google.golang.org/grpc" - packages = [".","balancer","balancer/base","balancer/roundrobin","codes","connectivity","credentials","encoding","grpclb/grpc_lb_v1/messages","grpclog","health","health/grpc_health_v1","internal","keepalive","metadata","naming","peer","resolver","resolver/dns","resolver/passthrough","stats","status","tap","transport"] - revision = "7cea4cc846bcf00cbb27595b07da5de875ef7de9" - version = "v1.9.1" + packages = [ + ".", + "balancer", + "balancer/base", + "balancer/roundrobin", + "codes", + "connectivity", + "credentials", + "encoding", + "grpclb/grpc_lb_v1/messages", + "grpclog", + "health", + "health/grpc_health_v1", + "internal", + "keepalive", + "metadata", + "naming", + "peer", + "resolver", + "resolver/dns", + "resolver/passthrough", + "stats", + "status", + "tap", + "transport" + ] + revision = "6b51017f791ae1cfbec89c52efdf444b13b550ef" + version = "v1.9.2" [[projects]] branch = "v3" @@ -474,6 +625,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "1824c50aa4685fb22643ab89d45457238d9e6c8f7980315c2a41bb35222ea1a7" + inputs-digest = "98e8d8f5fb21fe448aeb3db41c9fed85fe3bf80400e553211cf39a9c05720e01" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 577bb1654a2..144dcc1e4af 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,4 +1,3 @@ - # Gopkg.toml example # # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md @@ -20,6 +19,11 @@ # name = "github.com/x/y" # version = "2.4.0" +ignored = [ + "github.com/grafana/grafana/data/*", + "github.com/grafana/grafana/public/*", + "github.com/grafana/grafana/node_modules/*" + ] [[constraint]] name = "github.com/BurntSushi/toml" @@ -38,10 +42,6 @@ branch = "master" name = "github.com/benbjohnson/clock" -[[constraint]] - name = "github.com/bitly/go-simplejson" - version = "0.5.0" - [[constraint]] branch = "master" name = "github.com/bmizerany/assert" @@ -76,7 +76,8 @@ [[constraint]] name = "github.com/go-sql-driver/mysql" - version = "1.3.0" + revision = "2cc627ac8defc45d65066ae98f898166f580f9a4" + #version = "1.3.0" //keeping this since we would rather depend on version then commit [[constraint]] name = "github.com/go-stack/stack" @@ -174,7 +175,7 @@ [[constraint]] name = "gopkg.in/gomail.v2" - branch = "v2" + branch = "v2" [[constraint]] name = "gopkg.in/ini.v1" @@ -190,5 +191,5 @@ [prune] non-go = true - test-go = true + go-tests = true unused-packages = true 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/conf/defaults.ini b/conf/defaults.ini index ccf968df1a3..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 @@ -474,7 +477,7 @@ sampler_param = 1 #################################### External Image Storage ############## [external_image_storage] # You can choose between (s3, webdav, gcs, azure_blob, local) -provider = local +provider = [external_image_storage.s3] bucket_url = @@ -501,4 +504,4 @@ account_key = container_name = [external_image_storage.local] -# does not require any configuration \ No newline at end of file +# does not require any configuration 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/docker/blocks/mysql_tests/docker-compose.yaml b/docker/blocks/mysql_tests/docker-compose.yaml index c6c3097d463..3c59b66b5ac 100644 --- a/docker/blocks/mysql_tests/docker-compose.yaml +++ b/docker/blocks/mysql_tests/docker-compose.yaml @@ -7,4 +7,7 @@ MYSQL_PASSWORD: password ports: - "3306:3306" + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro tmpfs: /var/lib/mysql:rw 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/ diff --git a/docs/Dockerfile b/docs/Dockerfile index 7679f2b7e4b..faf90ea0ecd 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -9,5 +9,6 @@ FROM grafana/docs-base:latest COPY config.toml /site COPY awsconfig /site +COPY versions.json /site/static/js VOLUME ["/site/content"] diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 3b348427162..453d169457b 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -152,7 +152,7 @@ Prometheus Alertmanager | `prometheus-alertmanager` | no Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessable (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports Amazon S3, Webdav, Google Cloud Storage and Azure Blob Storage. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file. -By default the local image store is used which allows Grafana to serve the images by itself. Be aware that some notifiers requires public access to the image to be able to include it in the notification. +Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If your using local image uploader, your Grafana instance need to be accessible by the internet. Currently only the Email Channels attaches images if no external image store is specified. To include images in alert notifications for other channels then you need to set up an external image store. diff --git a/docs/sources/guides/whats-new-in-v5.md b/docs/sources/guides/whats-new-in-v5.md index ca66a3d7f3f..8779ef4241f 100644 --- a/docs/sources/guides/whats-new-in-v5.md +++ b/docs/sources/guides/whats-new-in-v5.md @@ -35,4 +35,22 @@ Each folder has its own page where you can set permisions for the folder or sing ## Teams -Teams are a new concept for Grafana. Teams are simply a group of users that can be given persmisions for folders or dashboards. Only an admin can create teams. \ No newline at end of file +Teams are a new concept for Grafana. Teams are simply a group of users that can be given permissions for folders or dashboards. Only an admin can create teams. + +# Dashboard model + +We are introducing a new identifier in the dashboard JSON model. The new identifier will be a X long uid. We are also changing the route for getting dashboards to use this id instead (we will keep supporting the old route for backward compatibility). This will make it possible to change the title on dashboards without breaking links. Sharing dashboards between instances become much easier since the uid is unique (unique enough) and the old numeric id always depends on the instance and might cause a conflict. This might seem like a small change, but we are incredibly excited about it since it will make it much easier to manage, collaborate and navigate between dashboards + +# Provisioning Grafana from configuration + +In previous versions of Grafana, you could use the API for provisioning. But that required the service to be running before you started creating dashboards and you also needed to set up credentials for authentication. In 5.0 we decided to improve this experience and enable people to provision using config files instead. Not only will this make gitops more natural, and it will also allow people to run Grafana as a stateless application. + +In 5.0 we added support for provisioning data sources and dashboards. We will add support for provisioning more parts of Grafana in the future. + +## Data sources + +It's now possible to create data sources in Grafana only using config files. These data sources are by default not editable from the Grafana GUI. Its also possible to update and delete data sources from the config, which makes it possible to manage data sources only thru configuration. More info in the [data source provisioning docs](/administration/provisioning/#datasources) + +## Dashboards + +We also deprecated [dashboard.json] in favor of our new dashboard provisioner that keeps dashboards on disk in sync with Grafana. The dashboard provisioner have multiple advantages over the old [dashboard.json] feature. Instead of storing the dashboard in memory we now insert the dashboard into the database, which makes it possible to use it with dashboard folders, permissions, built-in annotations and other features in Grafana that expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards) \ No newline at end of file diff --git a/docs/sources/installation/behind_proxy.md b/docs/sources/installation/behind_proxy.md index f151a66662e..f1a00a5b1cc 100644 --- a/docs/sources/installation/behind_proxy.md +++ b/docs/sources/installation/behind_proxy.md @@ -68,6 +68,23 @@ server { } } ``` + +#### HAProxy configuration with sub path +```bash +frontend http-in + bind *:80 + use_backend grafana_backend if { path /grafana } or { path_beg /grafana/ } + +backend grafana_backend + # Requires haproxy >= 1.6 + http-request set-path %[path,regsub(^/grafana/?,/)] + + # Works for haproxy < 1.6 + # reqrep ^([^\ ]*\ /)grafana[/]?(.*) \1\2 + + server grafana localhost:3000 +``` + ### IIS URL Rewrite Rule (Windows) with Subpath IIS requires that the URL Rewrite module is installed. diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index d61d4167b5a..59c2e0345ea 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/docs/versions.json b/docs/versions.json new file mode 100644 index 00000000000..03cb40f0e1f --- /dev/null +++ b/docs/versions.json @@ -0,0 +1,9 @@ +[ + { "version": "v5.0", "path": "/v5.0", "archived": false }, + { "version": "v4.6", "path": "/", "archived": false, "current": true }, + { "version": "v4.5", "path": "/v4.5", "archived": true }, + { "version": "v4.4", "path": "/v4.4", "archived": true }, + { "version": "v4.3", "path": "/v4.3", "archived": true }, + { "version": "v4.1", "path": "/v4.1", "archived": true }, + { "version": "v3.1", "path": "/v3.1", "archived": true } +] 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/plugins/datasource/wrapper/datasource_plugin_wrapper.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go index 0afa2a400e4..f9c9f9d3b16 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go @@ -2,6 +2,7 @@ package wrapper import ( "context" + "errors" "fmt" "github.com/grafana/grafana/pkg/components/null" @@ -67,11 +68,16 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou } for _, r := range pbres.Results { - res.Results[r.RefId] = &tsdb.QueryResult{ + qr := &tsdb.QueryResult{ RefId: r.RefId, Series: []*tsdb.TimeSeries{}, } + if r.Error != "" { + qr.Error = errors.New(r.Error) + qr.ErrorString = r.Error + } + for _, s := range r.GetSeries() { points := tsdb.TimeSeriesPoints{} @@ -80,7 +86,7 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou points = append(points, po) } - res.Results[r.RefId].Series = append(res.Results[r.RefId].Series, &tsdb.TimeSeries{ + qr.Series = append(qr.Series, &tsdb.TimeSeries{ Name: s.Name, Tags: s.Tags, Points: points, @@ -91,7 +97,9 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou if err != nil { return nil, err } - res.Results[r.RefId].Tables = mappedTables + qr.Tables = mappedTables + + res.Results[r.RefId] = qr } return res, nil 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/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index a602ca71df3..ab9e85f4d38 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -5,20 +5,25 @@ import ( "path/filepath" "strings" + "github.com/grafana/grafana/pkg/log" yaml "gopkg.in/yaml.v2" ) type configReader struct { path string + log log.Logger } func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { + var dashboards []*DashboardsAsConfig + files, err := ioutil.ReadDir(cr.path) + if err != nil { - return nil, err + cr.log.Error("cant read dashboard provisioning files from directory", "path", cr.path) + return dashboards, nil } - var dashboards []*DashboardsAsConfig for _, file := range files { if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") { continue @@ -30,13 +35,13 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { return nil, err } - var datasource []*DashboardsAsConfig - err = yaml.Unmarshal(yamlFile, &datasource) + var dashCfg []*DashboardsAsConfig + err = yaml.Unmarshal(yamlFile, &dashCfg) if err != nil { return nil, err } - dashboards = append(dashboards, datasource...) + dashboards = append(dashboards, dashCfg...) } for i := range dashboards { diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index faca34438d8..bb960a72094 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -3,6 +3,7 @@ package dashboards import ( "testing" + "github.com/grafana/grafana/pkg/log" . "github.com/smartystreets/goconvey/convey" ) @@ -16,8 +17,8 @@ func TestDashboardsAsConfig(t *testing.T) { Convey("Can read config file", func() { - cfgProvifer := configReader{path: simpleDashboardConfig} - cfg, err := cfgProvifer.readConfig() + cfgProvider := configReader{path: simpleDashboardConfig, log: log.New("test-logger")} + cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) } @@ -47,16 +48,26 @@ func TestDashboardsAsConfig(t *testing.T) { So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") }) - Convey("Should skip broken config files", func() { + Convey("Should skip invalid path", func() { - cfgProvifer := configReader{path: brokenConfigs} - cfg, err := cfgProvifer.readConfig() + cfgProvider := configReader{path: "/invalid-directory", log: log.New("test-logger")} + cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) } So(len(cfg), ShouldEqual, 0) + }) + Convey("Should skip broken config files", func() { + + cfgProvider := configReader{path: brokenConfigs, log: log.New("test-logger")} + cfg, err := cfgProvider.readConfig() + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + + So(len(cfg), ShouldEqual, 0) }) }) } diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 1ee0f78497d..a5349517bbe 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -14,9 +14,10 @@ type DashboardProvisioner struct { } func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) { + log := log.New("provisioning.dashboard") d := &DashboardProvisioner{ - cfgReader: &configReader{path: configDirectory}, - log: log.New("provisioning.dashboard"), + cfgReader: &configReader{path: configDirectory, log: log}, + log: log, ctx: ctx, } diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index ce631c565d4..aa1308ffa29 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -25,13 +25,13 @@ func Provision(configDirectory string) error { type DatasourceProvisioner struct { log log.Logger - cfgProvider configReader + cfgProvider *configReader } func newDatasourceProvisioner(log log.Logger) DatasourceProvisioner { return DatasourceProvisioner{ log: log, - cfgProvider: configReader{}, + cfgProvider: &configReader{log: log}, } } @@ -95,15 +95,19 @@ func (dc *DatasourceProvisioner) deleteDatasources(dsToDelete []*DeleteDatasourc return nil } -type configReader struct{} +type configReader struct { + log log.Logger +} + +func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { + var datasources []*DatasourcesAsConfig -func (configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { files, err := ioutil.ReadDir(path) if err != nil { - return nil, err + cr.log.Error("cant read datasource provisioning files from directory", "path", path) + return datasources, nil } - var datasources []*DatasourcesAsConfig for _, file := range files { if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { filename, _ := filepath.Abs(filepath.Join(path, file.Name())) diff --git a/pkg/services/provisioning/datasources/datasources_test.go b/pkg/services/provisioning/datasources/datasources_test.go index f3252c28d9d..00dc59f6617 100644 --- a/pkg/services/provisioning/datasources/datasources_test.go +++ b/pkg/services/provisioning/datasources/datasources_test.go @@ -11,7 +11,7 @@ import ( ) var ( - logger log.Logger = log.New("fake.logger") + logger log.Logger = log.New("fake.log") oneDatasourcesConfig string = "" twoDatasourcesConfig string = "./test-configs/two-datasources" twoDatasourcesConfigPurgeOthers string = "./test-configs/insert-two-delete-two" @@ -115,12 +115,23 @@ func TestDatasourceAsConfig(t *testing.T) { }) Convey("broken yaml should return error", func() { - _, err := configReader{}.readConfig(brokenYaml) + reader := &configReader{} + _, err := reader.readConfig(brokenYaml) So(err, ShouldNotBeNil) }) + Convey("skip invalid directory", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + cfg, err := cfgProvifer.readConfig("./invalid-directory") + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + + So(len(cfg), ShouldEqual, 0) + }) + Convey("can read all properties", func() { - cfgProvifer := configReader{} + cfgProvifer := &configReader{log: log.New("test logger")} cfg, err := cfgProvifer.readConfig(allProperties) if err != nil { t.Fatalf("readConfig return an error %v", err) diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 3b0c89e02ef..8d805c21685 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -176,6 +176,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { folder.has_acl = ` + dialect.BooleanStr(false) + ` ) AND da.dashboard_id = -1 + ORDER BY 1 ASC ` query.Result = make([]*m.DashboardAclInfoDTO, 0) 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 8cdb94bd413..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") @@ -578,7 +580,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/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index ba37f98922b..251527ab4e5 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -215,6 +215,21 @@ func transformToTable(data []suggestData, result *tsdb.QueryResult) { result.Meta.Set("rowCount", len(data)) } +func parseMultiSelectValue(input string) []string { + trimmedInput := strings.TrimSpace(input) + + if strings.HasPrefix(trimmedInput, "{") { + values := strings.Split(strings.TrimRight(strings.TrimLeft(trimmedInput, "{"), "}"), ",") + trimValues := make([]string, len(values)) + for i, v := range values { + trimValues[i] = strings.TrimSpace(v) + } + return trimValues + } else { + return []string{trimmedInput} + } +} + // Whenever this list is updated, frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { @@ -378,15 +393,19 @@ func (e *CloudWatchExecutor) handleGetEbsVolumeIds(ctx context.Context, paramete return nil, err } - instanceIds := []*string{aws.String(instanceId)} + instanceIds := aws.StringSlice(parseMultiSelectValue(instanceId)) instances, err := e.ec2DescribeInstances(region, nil, instanceIds) if err != nil { return nil, err } result := make([]suggestData, 0) - for _, mapping := range instances.Reservations[0].Instances[0].BlockDeviceMappings { - result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId}) + for _, reservation := range instances.Reservations { + for _, instance := range reservation.Instances { + for _, mapping := range instance.BlockDeviceMappings { + result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId}) + } + } } return result, nil diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 255b343a33a..bf87e7b7d41 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/bmizerany/assert" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" @@ -114,4 +115,85 @@ func TestCloudWatchMetrics(t *testing.T) { So(result[0].Text, ShouldEqual, "i-12345678") }) }) + + Convey("When calling handleGetEbsVolumeIds", t, func() { + + executor := &CloudWatchExecutor{ + ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + { + InstanceId: aws.String("i-1"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-1-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-1-2")}}, + }, + }, + { + InstanceId: aws.String("i-2"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-2-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-2-2")}}, + }, + }, + }, + }, + { + Instances: []*ec2.Instance{ + { + InstanceId: aws.String("i-3"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-3-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-3-2")}}, + }, + }, + { + InstanceId: aws.String("i-4"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-4-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-4-2")}}, + }, + }, + }, + }, + }, + }}, + } + + json := simplejson.New() + json.Set("region", "us-east-1") + json.Set("instanceId", "{i-1, i-2, i-3, i-4}") + result, _ := executor.handleGetEbsVolumeIds(context.Background(), json, &tsdb.TsdbQuery{}) + + Convey("Should return all 8 VolumeIds", func() { + So(len(result), ShouldEqual, 8) + So(result[0].Text, ShouldEqual, "vol-1-1") + So(result[1].Text, ShouldEqual, "vol-1-2") + So(result[2].Text, ShouldEqual, "vol-2-1") + So(result[3].Text, ShouldEqual, "vol-2-2") + So(result[4].Text, ShouldEqual, "vol-3-1") + So(result[5].Text, ShouldEqual, "vol-3-2") + So(result[6].Text, ShouldEqual, "vol-4-1") + So(result[7].Text, ShouldEqual, "vol-4-2") + }) + }) +} + +func TestParseMultiSelectValue(t *testing.T) { + + var values []string + + values = parseMultiSelectValue(" i-someInstance ") + assert.Equal(t, []string{"i-someInstance"}, values) + + values = parseMultiSelectValue("{i-05}") + assert.Equal(t, []string{"i-05"}, values) + + values = parseMultiSelectValue(" {i-01, i-03, i-04} ") + assert.Equal(t, []string{"i-01", "i-03", "i-04"}, values) + + values = parseMultiSelectValue("i-{01}") + assert.Equal(t, []string{"i-{01}"}, values) + } diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index c00b7b2181b..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'); }); @@ -167,6 +168,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); diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index 31a4cd5a57a..e0106740d3a 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -45,12 +45,13 @@
Specify an URL (relative or absolute)
- Use special variables to specify cell values: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.