diff --git a/conf/defaults.ini b/conf/defaults.ini index 37e1ee2c7df..6fc4cf2e4de 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -106,6 +106,22 @@ path = grafana.db # For "sqlite3" only. cache mode setting used for connecting to the database cache_mode = private +#################################### Login ############################### + +[login] + +# Login cookie name +cookie_name = grafana_session + +# How many days an session can be unused before we inactivate it +login_remember_days = 7 + +# How often should the login token be rotated. default to '10m' +rotate_token_minutes = 10 + +# How long should Grafana keep expired tokens before deleting them +delete_expired_token_after_days = 30 + #################################### Session ############################# [session] # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file" @@ -175,11 +191,6 @@ admin_password = admin # used for signing secret_key = SW2YcwTIb9zpOOhoPsMm -# Auto-login remember days -login_remember_days = 7 -cookie_username = grafana_user -cookie_remember_name = grafana_remember - # disable gravatar profile images disable_gravatar = false @@ -189,6 +200,9 @@ data_source_proxy_whitelist = # disable protection against brute force login attempts disable_brute_force_login_protection = false +# set cookies as https only. default is false +https_flag_cookies = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/conf/sample.ini b/conf/sample.ini index 96b92db6f48..0f1c02dc231 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -102,6 +102,22 @@ log_queries = # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared) ;cache_mode = private +#################################### Login ############################### + +[login] + +# Login cookie name +;cookie_name = grafana_session + +# How many days an session can be unused before we inactivate it +;login_remember_days = 7 + +# How often should the login token be rotated. default to '10' +;rotate_token_minutes = 10 + +# How long should Grafana keep expired tokens before deleting them +;delete_expired_token_after_days = 30 + #################################### Session #################################### [session] # Either "memory", "file", "redis", "mysql", "postgres", default is "file" @@ -162,11 +178,6 @@ log_queries = # used for signing ;secret_key = SW2YcwTIb9zpOOhoPsMm -# Auto-login remember days -;login_remember_days = 7 -;cookie_username = grafana_user -;cookie_remember_name = grafana_remember - # disable gravatar profile images ;disable_gravatar = false @@ -176,6 +187,9 @@ log_queries = # disable protection against brute force login attempts ;disable_brute_force_login_protection = false +# set cookies as https only. default is false +;https_flag_cookies = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index 1195e2a977c..504ee86404d 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -54,7 +54,8 @@ services: # - GF_DATABASE_SSL_MODE=disable # - GF_SESSION_PROVIDER=postgres # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable - - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug + - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug + - GF_LOGIN_ROTATE_TOKEN_MINUTES=2 ports: - 3000 depends_on: diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md new file mode 100644 index 00000000000..8e724637acb --- /dev/null +++ b/devenv/docker/loadtest/README.md @@ -0,0 +1,69 @@ +# Grafana load test + +Runs load tests and checks using [k6](https://k6.io/). + +## Prerequisites + +Docker + +## Run + +Run load test for 15 minutes: + +```bash +$ ./run.sh +``` + +Run load test for custom duration: + +```bash +$ ./run.sh -d 10s +``` + +Example output: + +```bash + + /\ |‾‾| /‾‾/ /‾/ + /\ / \ | |_/ / / / + / \/ \ | | / ‾‾\ + / \ | |‾\ \ | (_) | + / __________ \ |__| \__\ \___/ .io + + execution: local + output: - + script: src/auth_token_test.js + + duration: 15m0s, iterations: - + vus: 2, max: 2 + + done [==========================================================] 15m0s / 15m0s + + █ user auth token test + + █ user authenticates thru ui with username and password + + ✓ response status is 200 + ✓ response has cookie 'grafana_session' with 32 characters + + █ batch tsdb requests + + ✓ response status is 200 + + checks.....................: 100.00% ✓ 32844 ✗ 0 + data_received..............: 411 MB 457 kB/s + data_sent..................: 12 MB 14 kB/s + group_duration.............: avg=95.64ms min=16.42ms med=94.35ms max=307.52ms p(90)=137.78ms p(95)=146.75ms + http_req_blocked...........: avg=1.27ms min=942ns med=610.08µs max=48.32ms p(90)=2.92ms p(95)=4.25ms + http_req_connecting........: avg=1.06ms min=0s med=456.79µs max=47.19ms p(90)=2.55ms p(95)=3.78ms + http_req_duration..........: avg=58.16ms min=1ms med=52.59ms max=293.35ms p(90)=109.53ms p(95)=120.19ms + http_req_receiving.........: avg=38.98µs min=6.43µs med=32.55µs max=16.2ms p(90)=64.63µs p(95)=78.8µs + http_req_sending...........: avg=328.66µs min=8.09µs med=110.77µs max=44.13ms p(90)=552.65µs p(95)=1.09ms + http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...........: avg=57.79ms min=935.02µs med=52.15ms max=293.06ms p(90)=109.04ms p(95)=119.71ms + http_reqs..................: 34486 38.317775/s + iteration_duration.........: avg=1.09s min=1.81µs med=1.09s max=1.3s p(90)=1.13s p(95)=1.14s + iterations.................: 1642 1.824444/s + vus........................: 2 min=2 max=2 + vus_max....................: 2 min=2 max=2 +``` diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js new file mode 100644 index 00000000000..e1356fb6f9a --- /dev/null +++ b/devenv/docker/loadtest/auth_token_test.js @@ -0,0 +1,71 @@ +import { sleep, check, group } from 'k6'; +import { createClient, createBasicAuthClient } from './modules/client.js'; +import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js'; + +export let options = { + noCookiesReset: true +}; + +let endpoint = __ENV.URL || 'http://localhost:3000'; +const client = createClient(endpoint); + +export const setup = () => { + const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin'); + const orgId = createTestOrgIfNotExists(basicAuthClient); + const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient); + client.withOrgId(orgId); + return { + orgId: orgId, + datasourceId: datasourceId, + }; +} + +export default (data) => { + group("user auth token test", () => { + if (__ITER === 0) { + group("user authenticates thru ui with username and password", () => { + let res = client.ui.login('admin', 'admin'); + + check(res, { + 'response status is 200': (r) => r.status === 200, + 'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32, + }); + }); + } + + if (__ITER !== 0) { + group("batch tsdb requests", () => { + const batchCount = 20; + const requests = []; + const payload = { + from: '1547765247624', + to: '1547768847624', + queries: [{ + refId: 'A', + scenarioId: 'random_walk', + intervalMs: 10000, + maxDataPoints: 433, + datasourceId: data.datasourceId, + }] + }; + + requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' }); + + for (let n = 0; n < batchCount; n++) { + requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload }); + } + + let responses = client.batch(requests); + for (let n = 0; n < batchCount; n++) { + check(responses[n], { + 'response status is 200': (r) => r.status === 200, + }); + } + }); + } + }); + + sleep(1) +} + +export const teardown = (data) => {} diff --git a/devenv/docker/loadtest/modules/client.js b/devenv/docker/loadtest/modules/client.js new file mode 100644 index 00000000000..bda0da64564 --- /dev/null +++ b/devenv/docker/loadtest/modules/client.js @@ -0,0 +1,187 @@ +import http from "k6/http"; +import encoding from 'k6/encoding'; + +export const UIEndpoint = class UIEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + login(username, pwd) { + const payload = { user: username, password: pwd }; + return this.httpClient.formPost('/login', payload); + } +} + +export const DatasourcesEndpoint = class DatasourcesEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getById(id) { + return this.httpClient.get(`/datasources/${id}`); + } + + getByName(name) { + return this.httpClient.get(`/datasources/name/${name}`); + } + + create(payload) { + return this.httpClient.post(`/datasources`, JSON.stringify(payload)); + } + + delete(id) { + return this.httpClient.delete(`/datasources/${id}`); + } +} + +export const OrganizationsEndpoint = class OrganizationsEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getById(id) { + return this.httpClient.get(`/orgs/${id}`); + } + + getByName(name) { + return this.httpClient.get(`/orgs/name/${name}`); + } + + create(name) { + let payload = { + name: name, + }; + return this.httpClient.post(`/orgs`, JSON.stringify(payload)); + } + + delete(id) { + return this.httpClient.delete(`/orgs/${id}`); + } +} + +export const GrafanaClient = class GrafanaClient { + constructor(httpClient) { + httpClient.onBeforeRequest = this.onBeforeRequest; + this.raw = httpClient; + this.ui = new UIEndpoint(httpClient); + this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api')); + this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api')); + } + + batch(requests) { + return this.raw.batch(requests); + } + + withOrgId(orgId) { + this.orgId = orgId; + } + + onBeforeRequest(params) { + if (this.orgId && this.orgId > 0) { + params = params.headers || {}; + params.headers["X-Grafana-Org-Id"] = this.orgId; + } + } +} + +export const BaseClient = class BaseClient { + constructor(url, subUrl) { + if (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + + if (subUrl.endsWith('/')) { + subUrl = subUrl.substring(0, subUrl.length - 1); + } + + this.url = url + subUrl; + this.onBeforeRequest = () => {}; + } + + withUrl(subUrl) { + let c = new BaseClient(this.url, subUrl); + c.onBeforeRequest = this.onBeforeRequest; + return c; + } + + beforeRequest(params) { + + } + + get(url, params) { + params = params || {}; + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.get(this.url + url, params); + } + + formPost(url, body, params) { + params = params || {}; + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.post(this.url + url, body, params); + } + + post(url, body, params) { + params = params || {}; + params.headers = params.headers || {}; + params.headers['Content-Type'] = 'application/json'; + + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.post(this.url + url, body, params); + } + + delete(url, params) { + params = params || {}; + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.del(this.url + url, null, params); + } + + batch(requests) { + for (let n = 0; n < requests.length; n++) { + let params = requests[n].params || {}; + params.headers = params.headers || {}; + params.headers['Content-Type'] = 'application/json'; + this.beforeRequest(params); + this.onBeforeRequest(params); + requests[n].params = params; + requests[n].url = this.url + requests[n].url; + if (requests[n].body) { + requests[n].body = JSON.stringify(requests[n].body); + } + } + + return http.batch(requests); + } +} + +export class BasicAuthClient extends BaseClient { + constructor(url, subUrl, username, password) { + super(url, subUrl); + this.username = username; + this.password = password; + } + + withUrl(subUrl) { + let c = new BasicAuthClient(this.url, subUrl, this.username, this.password); + c.onBeforeRequest = this.onBeforeRequest; + return c; + } + + beforeRequest(params) { + params = params || {}; + params.headers = params.headers || {}; + let token = `${this.username}:${this.password}`; + params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`; + } +} + +export const createClient = (url) => { + return new GrafanaClient(new BaseClient(url, '')); +} + +export const createBasicAuthClient = (url, username, password) => { + return new GrafanaClient(new BasicAuthClient(url, '', username, password)); +} diff --git a/devenv/docker/loadtest/modules/util.js b/devenv/docker/loadtest/modules/util.js new file mode 100644 index 00000000000..af6d4cdac09 --- /dev/null +++ b/devenv/docker/loadtest/modules/util.js @@ -0,0 +1,35 @@ +export const createTestOrgIfNotExists = (client) => { + let orgId = 0; + let res = client.orgs.getByName('k6'); + if (res.status === 404) { + res = client.orgs.create('k6'); + if (res.status !== 200) { + throw new Error('Expected 200 response status when creating org'); + } + orgId = res.json().orgId; + } else { + orgId = res.json().id; + } + + client.withOrgId(orgId); + return orgId; +} + +export const createTestdataDatasourceIfNotExists = (client) => { + const payload = { + access: 'proxy', + isDefault: false, + name: 'k6-testdata', + type: 'testdata', + }; + + let res = client.datasources.getByName(payload.name); + if (res.status === 404) { + res = client.datasources.create(payload); + if (res.status !== 200) { + throw new Error('Expected 200 response status when creating datasource'); + } + } + + return res.json().id; +} diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh new file mode 100755 index 00000000000..474d75383b6 --- /dev/null +++ b/devenv/docker/loadtest/run.sh @@ -0,0 +1,24 @@ +#/bin/bash + +PWD=$(pwd) + +run() { + duration='15m' + url='http://localhost:3000' + + while getopts ":d:u:" o; do + case "${o}" in + d) + duration=${OPTARG} + ;; + u) + url=${OPTARG} + ;; + esac + done + shift $((OPTIND-1)) + + docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js +} + +run "$@" diff --git a/pkg/api/api.go b/pkg/api/api.go index 0526ee80afe..07cb712f794 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() { // not logged in views r.Get("/", reqSignedIn, hs.Index) - r.Get("/logout", Logout) - r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost)) - r.Get("/login/:name", quota("session"), OAuthLogin) + r.Get("/logout", hs.Logout) + r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost)) + r.Get("/login/:name", quota("session"), hs.OAuthLogin) r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) @@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/signup", hs.Index) r.Get("/api/user/signup/options", Wrap(GetSignUpOptions)) r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp)) - r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2)) + r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2)) // invited r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode)) - r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite)) + r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite)) // reset password r.Get("/user/password/send-reset-email", hs.Index) @@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() { r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) // api renew session based on remember cookie - r.Get("/api/login/ping", quota("session"), LoginAPIPing) + r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing) // authed api r.Group("/api", func(apiRoute routing.RouteRegister) { diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 8b66a7a468b..eb1f89e3f22 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -5,7 +5,6 @@ import ( "net/http/httptest" "path/filepath" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string + userAuthTokenService *fakeUserAuthTokenService } func (sc *scenarioContext) exec() { @@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{}, 0)) + sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService)) return sc } + +type fakeUserAuthTokenService struct { + initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool +} + +func newFakeUserAuthTokenService() *fakeUserAuthTokenService { + return &fakeUserAuthTokenService{ + initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { + return false + }, + } +} + +func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { + return s.initContextWithTokenProvider(ctx, orgID) +} + +func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { + return nil +} + +func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index d4d7b41bec5..7b7c1478a4c 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -11,14 +11,8 @@ import ( "path" "time" - "github.com/grafana/grafana/pkg/api/routing" - "github.com/prometheus/client_golang/prometheus" - - "github.com/prometheus/client_golang/prometheus/promhttp" - - macaron "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/api/live" + "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -27,11 +21,16 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + macaron "gopkg.in/macaron.v1" ) func init() { @@ -49,13 +48,14 @@ type HTTPServer struct { streamManager *live.StreamManager httpSrv *http.Server - RouteRegister routing.RouteRegister `inject:""` - Bus bus.Bus `inject:""` - RenderService rendering.Service `inject:""` - Cfg *setting.Cfg `inject:""` - HooksService *hooks.HooksService `inject:""` - CacheService *cache.CacheService `inject:""` - DatasourceCache datasources.CacheService `inject:""` + RouteRegister routing.RouteRegister `inject:""` + Bus bus.Bus `inject:""` + RenderService rendering.Service `inject:""` + Cfg *setting.Cfg `inject:""` + HooksService *hooks.HooksService `inject:""` + CacheService *cache.CacheService `inject:""` + DatasourceCache datasources.CacheService `inject:""` + AuthTokenService auth.UserAuthTokenService `inject:""` } func (hs *HTTPServer) Init() error { @@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error { hs.macaron = hs.newMacaron() hs.registerRoutes() + session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime) + return nil } @@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.Use(hs.healthHandler) m.Use(hs.metricsEndpoint) - m.Use(middleware.GetContextHandler()) - m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime)) + m.Use(middleware.GetContextHandler(hs.AuthTokenService)) m.Use(middleware.OrgRedirect()) // needs to be after context handler diff --git a/pkg/api/login.go b/pkg/api/login.go index 05afc40e59a..50c62e0835a 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -1,6 +1,8 @@ package api import ( + "encoding/hex" + "net/http" "net/url" "github.com/grafana/grafana/pkg/api/dtos" @@ -9,12 +11,13 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) const ( - ViewIndex = "index" + ViewIndex = "index" + LoginErrorCookieName = "login_error" ) func (hs *HTTPServer) LoginView(c *m.ReqContext) { @@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) { viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["disableLoginForm"] = setting.DisableLoginForm - if loginError, ok := c.Session.Get("loginError").(string); ok { - c.Session.Delete("loginError") + if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { + deleteCookie(c, LoginErrorCookieName) viewData.Settings["loginError"] = loginError } @@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) { return } - if !tryLoginUsingRememberCookie(c) { + if !c.IsSignedIn { c.HTML(200, ViewIndex, viewData) return } @@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool { return false } -func tryLoginUsingRememberCookie(c *m.ReqContext) bool { - // Check auto-login. - uname := c.GetCookie(setting.CookieUserName) - if len(uname) == 0 { - return false +func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response { + if c.IsSignedIn || c.IsAnonymous { + return JSON(200, "Logged in") } - isSucceed := false - defer func() { - if !isSucceed { - log.Trace("auto-login cookie cleared: %s", uname) - c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") - c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/") - return - } - }() - - userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname} - if err := bus.Dispatch(&userQuery); err != nil { - return false - } - - user := userQuery.Result - - // validate remember me cookie - signingKey := user.Rands + user.Password - if len(signingKey) < 10 { - c.Logger.Error("Invalid user signingKey") - return false - } - - if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login { - return false - } - - isSucceed = true - loginUserWithUser(user, c) - return true + return Error(401, "Unauthorized", nil) } -func LoginAPIPing(c *m.ReqContext) { - if !tryLoginUsingRememberCookie(c) { - c.JsonApiErr(401, "Unauthorized", nil) - return - } - - c.JsonOK("Logged in") -} - -func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { +func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { if setting.DisableLoginForm { return Error(401, "Login is disabled", nil) } @@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { user := authQuery.User - loginUserWithUser(user, c) + hs.loginUserWithUser(user, c) result := map[string]interface{}{ "message": "Logged in", @@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { return JSON(200, result) } -func loginUserWithUser(user *m.User, c *m.ReqContext) { +func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { if user == nil { - log.Error(3, "User login with nil user") + hs.log.Error("User login with nil user") } - c.Resp.Header().Del("Set-Cookie") - - days := 86400 * setting.LogInRememberDays - if days > 0 { - c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/") - c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/") + err := hs.AuthTokenService.UserAuthenticatedHook(user, c) + if err != nil { + hs.log.Error("User auth hook failed", "error", err) } - - c.Session.RegenerateId(c.Context) - c.Session.Set(session.SESS_KEY_USERID, user.Id) } -func Logout(c *m.ReqContext) { - c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") - c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/") - c.Session.Destory(c.Context) +func (hs *HTTPServer) Logout(c *m.ReqContext) { + hs.AuthTokenService.UserSignedOutHook(c) + if setting.SignoutRedirectUrl != "" { c.Redirect(setting.SignoutRedirectUrl) } else { c.Redirect(setting.AppSubUrl + "/login") } } + +func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) { + cookie := ctx.GetCookie(cookieName) + if cookie == "" { + return "", false + } + + decoded, err := hex.DecodeString(cookie) + if err != nil { + return "", false + } + + decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey) + return string(decryptedError), err == nil +} + +func deleteCookie(ctx *m.ReqContext, cookieName string) { + ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/") +} + +func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error { + encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey) + if err != nil { + return err + } + + http.SetCookie(ctx.Resp, &http.Cookie{ + Name: cookieName, + MaxAge: 60, + Value: hex.EncodeToString(encryptedError), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: hs.Cfg.SecurityHTTPSCookies, + }) + + return nil +} diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index fe4fa93b621..4160d48733e 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -3,9 +3,11 @@ package api import ( "context" "crypto/rand" + "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "fmt" "io/ioutil" "net/http" @@ -18,12 +20,14 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/social" ) -var oauthLogger = log.New("oauth") +var ( + oauthLogger = log.New("oauth") + OauthStateCookieName = "oauth_state" +) func GenStateString() string { rnd := make([]byte, 32) @@ -31,7 +35,7 @@ func GenStateString() string { return base64.URLEncoding.EncodeToString(rnd) } -func OAuthLogin(ctx *m.ReqContext) { +func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { if setting.OAuthService == nil { ctx.Handle(404, "OAuth not enabled", nil) return @@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) { if errorParam != "" { errorDesc := ctx.Query("error_description") oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) - redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) + hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) return } code := ctx.Query("code") if code == "" { state := GenStateString() - ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state) + hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret) + hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60) if setting.OAuthService.OAuthInfos[name].HostedDomain == "" { ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline)) } else { @@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) { return } - savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string) - if !ok { + cookieState := ctx.GetCookie(OauthStateCookieName) + + // delete cookie + ctx.Resp.Header().Del("Set-Cookie") + hs.deleteCookie(ctx.Resp, OauthStateCookieName) + + if cookieState == "" { ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) return } - queryState := ctx.Query("state") - if savedState != queryState { + queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret) + oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState) + if cookieState != queryState { ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil) return } @@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) { userInfo, err := connect.UserInfo(client, token) if err != nil { if sErr, ok := err.(*social.Error); ok { - redirectWithError(ctx, sErr) + hs.redirectWithError(ctx, sErr) } else { ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) } @@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) { // validate that we got at least an email address if userInfo.Email == "" { - redirectWithError(ctx, login.ErrNoEmail) + hs.redirectWithError(ctx, login.ErrNoEmail) return } // validate that the email is allowed to login to grafana if !connect.IsEmailAllowed(userInfo.Email) { - redirectWithError(ctx, login.ErrEmailNotAllowed) + hs.redirectWithError(ctx, login.ErrEmailNotAllowed) return } @@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) { ExternalUser: extUser, SignupAllowed: connect.IsSignupAllowed(), } + err = bus.Dispatch(cmd) if err != nil { - redirectWithError(ctx, err) + hs.redirectWithError(ctx, err) return } // login - loginUserWithUser(cmd.Result, ctx) + hs.loginUserWithUser(cmd.Result, ctx) metrics.M_Api_Login_OAuth.Inc() @@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) { ctx.Redirect(setting.AppSubUrl + "/") } -func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { +func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) { + hs.writeCookie(w, name, "", -1) +} + +func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: name, + MaxAge: maxAge, + Value: value, + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: hs.Cfg.SecurityHTTPSCookies, + }) +} + +func hashStatecode(code, seed string) string { + hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed)) + return hex.EncodeToString(hashBytes[:]) +} + +func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { ctx.Logger.Error(err.Error(), v...) - ctx.Session.Set("loginError", err.Error()) + hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60) + ctx.Redirect(setting.AppSubUrl + "/login") } diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index dfb2cf045ed..835b03a2cc9 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response { }) } -func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { +func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} if err := bus.Dispatch(&query); err != nil { @@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res return rsp } - loginUserWithUser(user, c) + hs.loginUserWithUser(user, c) metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpInvite.Inc() diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 200a3ebc9d1..fe577dd9ef9 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response { return JSON(200, util.DynMap{"status": "SignUpCreated"}) } -func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { +func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { if !setting.AllowUserSignUp { return Error(401, "User signup is disabled", nil) } @@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { apiResponse["code"] = "redirect-to-select-org" } - loginUserWithUser(user, c) + hs.loginUserWithUser(user, c) metrics.M_Api_User_SignUpCompleted.Inc() return JSON(200, apiResponse) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 5faee1e3fa7..27248342c8d 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -7,7 +7,6 @@ import ( "gopkg.in/macaron.v1" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -17,16 +16,6 @@ type AuthOptions struct { ReqSignedIn bool } -func getRequestUserId(c *m.ReqContext) int64 { - userID := c.Session.Get(session.SESS_KEY_USERID) - - if userID != nil { - return userID.(int64) - } - - return 0 -} - func getApiKey(c *m.ReqContext) string { header := c.Req.Header.Get("Authorization") parts := strings.SplitN(header, " ", 2) diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index fc109ac707f..93ee577e3c6 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -16,7 +16,9 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" +var ( + AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" +) func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { if !setting.AuthProxyEnabled { @@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { return false } + defer func() { + if err := ctx.Session.Release(); err != nil { + ctx.Logger.Error("failed to save session data", "error", err) + } + }() + query := &m.GetSignedInUserQuery{OrgId: orgID} // if this session has already been authenticated by authProxy just load the user @@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error { return nil } +func getRequestUserId(c *m.ReqContext) int64 { + userID := c.Session.Get(session.SESS_KEY_USERID) + + if userID != nil { + return userID.(int64) + } + + return 0 +} + func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error { if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 { return nil diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index ace72d998eb..3722ac3058f 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -3,15 +3,15 @@ package middleware import ( "strconv" - "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + macaron "gopkg.in/macaron.v1" ) var ( @@ -21,12 +21,12 @@ var ( ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ) -func GetContextHandler() macaron.Handler { +func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler { return func(c *macaron.Context) { ctx := &m.ReqContext{ Context: c, SignedInUser: &m.SignedInUser{}, - Session: session.GetSession(), + Session: session.GetSession(), // should only be used by auth_proxy IsSignedIn: false, AllowAnonymous: false, SkipCache: false, @@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler { case initContextWithApiKey(ctx): case initContextWithBasicAuth(ctx, orgId): case initContextWithAuthProxy(ctx, orgId): - case initContextWithUserSessionCookie(ctx, orgId): + case ats.InitContextWithToken(ctx, orgId): case initContextWithAnonymousUser(ctx): } @@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool { return true } -func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool { - // initialize session - if err := ctx.Session.Start(ctx.Context); err != nil { - ctx.Logger.Error("Failed to start session", "error", err) - return false - } - - var userId int64 - if userId = getRequestUserId(ctx); userId == 0 { - return false - } - - query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId} - if err := bus.Dispatch(&query); err != nil { - ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err) - return false - } - - ctx.SignedInUser = query.Result - ctx.IsSignedIn = true - return true -} - func initContextWithApiKey(ctx *m.ReqContext) bool { var keyString string if keyString = getApiKey(ctx); keyString == "" { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index b9a8afce6c6..11740574d0b 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - ms "github.com/go-macaron/session" + msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/session" @@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) { So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty) }) - middlewareScenario("Non api request should init session", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").exec() - So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess") - }) - middlewareScenario("Invalid api key", func(sc *scenarioContext) { sc.apiKey = "invalid_key_test" sc.fakeReq("GET", "/").exec() @@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) { }) }) - middlewareScenario("UserId in session", func(sc *scenarioContext) { - - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() - - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) + middlewareScenario("Auth token service", func(sc *scenarioContext) { + var wasCalled bool + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + wasCalled = true + return false + } sc.fakeReq("GET", "/").exec() - Convey("should init context with user info", func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 12) + Convey("should call middleware", func() { + So(wasCalled, ShouldBeTrue) }) }) @@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) { return nil }) + setting.SessionOptions = msession.Options{} sc.fakeReq("GET", "/") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.exec() @@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { defer bus.ClearBusHandlers() sc := &scenarioContext{} + viewsPath, _ := filepath.Abs("../../public/views") sc.m = macaron.New() @@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.m.Use(GetContextHandler()) + session.Init(&msession.Options{}, 0) + sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} - sc.m.Use(Sessioner(&ms.Options{}, 0)) + setting.SessionOptions = msession.Options{} + sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) @@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) { } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - apiKey string - authHeader string - respJson map[string]interface{} - handlerFunc handlerFunc - defaultHandler macaron.Handler - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + apiKey string + authHeader string + respJson map[string]interface{} + handlerFunc handlerFunc + defaultHandler macaron.Handler + url string + userAuthTokenService *fakeUserAuthTokenService req *http.Request } @@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) + +type fakeUserAuthTokenService struct { + initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool +} + +func newFakeUserAuthTokenService() *fakeUserAuthTokenService { + return &fakeUserAuthTokenService{ + initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { + return false + }, + } +} + +func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { + return s.initContextWithTokenProvider(ctx, orgID) +} + +func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { + return nil +} + +func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {} diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go index db263c2a17a..ca63733946c 100644 --- a/pkg/middleware/org_redirect.go +++ b/pkg/middleware/org_redirect.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" - "gopkg.in/macaron.v1" ) diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index fa08154b250..46b8776fdcc 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" . "github.com/smartystreets/goconvey/convey" ) @@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) { Convey("Can redirect to correct org", t, func() { middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() - bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return nil }) - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} - return nil - }) + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12} + ctx.IsSignedIn = true + return true + } sc.m.Get("/", sc.defaultHandler) sc.fakeReq("GET", "/?orgId=3").exec() @@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) { }) middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() - bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return fmt.Errorf("") }) + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12} + ctx.IsSignedIn = true + return true + } + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} return nil diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 92c3d62674d..4f2203a5d3d 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) { }) middlewareScenario("with user logged in", func(sc *scenarioContext) { - // log us in, so we have a user_id and org_id in the context - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12} + ctx.IsSignedIn = true + return true + } - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { query.Result = &m.GlobalQuotaDTO{ Target: query.Target, diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index c92150f3b7d..e041d42e56b 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -4,13 +4,12 @@ import ( "path/filepath" "testing" - ms "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" - "gopkg.in/macaron.v1" + macaron "gopkg.in/macaron.v1" ) func TestRecoveryMiddleware(t *testing.T) { @@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.m.Use(GetContextHandler()) + sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} - sc.m.Use(Sessioner(&ms.Options{}, 0)) sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go deleted file mode 100644 index 19cfa368b49..00000000000 --- a/pkg/middleware/session.go +++ /dev/null @@ -1,21 +0,0 @@ -package middleware - -import ( - ms "github.com/go-macaron/session" - "gopkg.in/macaron.v1" - - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" -) - -func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler { - session.Init(options, sessionConnMaxLifetime) - - return func(ctx *m.ReqContext) { - ctx.Next() - - if err := ctx.Session.Release(); err != nil { - panic("session(release): " + err.Error()) - } - } -} diff --git a/pkg/models/context.go b/pkg/models/context.go index 7cb80a957c3..df970451304 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -3,18 +3,18 @@ package models import ( "strings" - "github.com/prometheus/client_golang/prometheus" - "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" + "gopkg.in/macaron.v1" ) type ReqContext struct { *macaron.Context *SignedInUser + // This should only be used by the auth_proxy Session session.SessionStore IsSignedIn bool diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go new file mode 100644 index 00000000000..7e9433c2d70 --- /dev/null +++ b/pkg/services/auth/auth_token.go @@ -0,0 +1,266 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +func init() { + registry.RegisterService(&UserAuthTokenServiceImpl{}) +} + +var ( + getTime = time.Now + UrgentRotateTime = 1 * time.Minute + oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often. +) + +// UserAuthTokenService are used for generating and validating user auth tokens +type UserAuthTokenService interface { + InitContextWithToken(ctx *models.ReqContext, orgID int64) bool + UserAuthenticatedHook(user *models.User, c *models.ReqContext) error + UserSignedOutHook(c *models.ReqContext) +} + +type UserAuthTokenServiceImpl struct { + SQLStore *sqlstore.SqlStore `inject:""` + ServerLockService *serverlock.ServerLockService `inject:""` + Cfg *setting.Cfg `inject:""` + log log.Logger +} + +// Init this service +func (s *UserAuthTokenServiceImpl) Init() error { + s.log = log.New("auth") + return nil +} + +func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool { + //auth User + unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName) + if unhashedToken == "" { + return false + } + + userToken, err := s.LookupToken(unhashedToken) + if err != nil { + ctx.Logger.Info("failed to look up user based on cookie", "error", err) + return false + } + + query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID} + if err := bus.Dispatch(&query); err != nil { + ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err) + return false + } + + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + + //rotate session token if needed. + rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent()) + if err != nil { + ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id) + return true + } + + if rotated { + s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds) + } + + return true +} + +func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) { + if setting.Env == setting.DEV { + ctx.Logger.Info("new token", "unhashed token", value) + } + + ctx.Resp.Header().Del("Set-Cookie") + cookie := http.Cookie{ + Name: s.Cfg.LoginCookieName, + Value: url.QueryEscape(value), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: s.Cfg.SecurityHTTPSCookies, + MaxAge: maxAge, + } + + http.SetCookie(ctx.Resp, &cookie) +} + +func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error { + userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent()) + if err != nil { + return err + } + + s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds) + return nil +} + +func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) { + s.writeSessionCookie(c, "", -1) +} + +func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) { + clientIP = util.ParseIPAddress(clientIP) + token, err := util.RandomHex(16) + if err != nil { + return nil, err + } + + hashedToken := hashToken(token) + + now := getTime().Unix() + + userToken := userAuthToken{ + UserId: userId, + AuthToken: hashedToken, + PrevAuthToken: hashedToken, + ClientIp: clientIP, + UserAgent: userAgent, + RotatedAt: now, + CreatedAt: now, + UpdatedAt: now, + SeenAt: 0, + AuthTokenSeen: false, + } + _, err = s.SQLStore.NewSession().Insert(&userToken) + if err != nil { + return nil, err + } + + userToken.UnhashedToken = token + + return &userToken, nil +} + +func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) { + hashedToken := hashToken(unhashedToken) + if setting.Env == setting.DEV { + s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) + } + + expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix() + + var userToken userAuthToken + exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken) + if err != nil { + return nil, err + } + + if !exists { + return nil, ErrAuthTokenNotFound + } + + if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen { + userTokenCopy := userToken + userTokenCopy.AuthTokenSeen = false + expireBefore := getTime().Add(-UrgentRotateTime).Unix() + affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy) + if err != nil { + return nil, err + } + + if affectedRows == 0 { + s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } else { + s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } + } + + if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken { + userTokenCopy := userToken + userTokenCopy.AuthTokenSeen = true + userTokenCopy.SeenAt = getTime().Unix() + affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy) + if err != nil { + return nil, err + } + + if affectedRows == 1 { + userToken = userTokenCopy + } + + if affectedRows == 0 { + s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } else { + s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } + } + + userToken.UnhashedToken = unhashedToken + + return &userToken, nil +} + +func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) { + if token == nil { + return false, nil + } + + now := getTime() + + needsRotation := false + rotatedAt := time.Unix(token.RotatedAt, 0) + if token.AuthTokenSeen { + needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute)) + } else { + needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime)) + } + + if !needsRotation { + return false, nil + } + + s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id) + + clientIP = util.ParseIPAddress(clientIP) + newToken, _ := util.RandomHex(16) + hashedToken := hashToken(newToken) + + // very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly + sql := ` + UPDATE user_auth_token + SET + seen_at = 0, + user_agent = ?, + client_ip = ?, + prev_auth_token = case when auth_token_seen = ? then auth_token else prev_auth_token end, + auth_token = ?, + auth_token_seen = ?, + rotated_at = ? + WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)` + + res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix()) + if err != nil { + return false, err + } + + affected, _ := res.RowsAffected() + s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId) + if affected > 0 { + token.UnhashedToken = newToken + return true, nil + } + + return false, nil +} + +func hashToken(token string) string { + hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) + return hex.EncodeToString(hashBytes[:]) +} diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go new file mode 100644 index 00000000000..2f75c660d9d --- /dev/null +++ b/pkg/services/auth/auth_token_test.go @@ -0,0 +1,339 @@ +package auth + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserAuthToken(t *testing.T) { + Convey("Test user auth token", t, func() { + ctx := createTestContext(t) + userAuthTokenService := ctx.tokenService + userID := int64(10) + + t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC) + getTime = func() time.Time { + return t + } + + Convey("When creating token", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.AuthTokenSeen, ShouldBeFalse) + + Convey("When lookup unhashed token should return user auth token", func() { + LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(LookupToken, ShouldNotBeNil) + So(LookupToken.UserId, ShouldEqual, userID) + So(LookupToken.AuthTokenSeen, ShouldBeTrue) + + storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id) + So(err, ShouldBeNil) + So(storedAuthToken, ShouldNotBeNil) + So(storedAuthToken.AuthTokenSeen, ShouldBeTrue) + }) + + Convey("When lookup hashed token should return user auth token not found error", func() { + LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(LookupToken, ShouldBeNil) + }) + }) + + Convey("expires correctly", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + _, err = userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + + getTime = func() time.Time { + return t.Add(time.Hour) + } + + refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + _, err = userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + + stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(stillGood, ShouldNotBeNil) + + getTime = func() time.Time { + return t.Add(24 * 7 * time.Hour) + } + notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(notGood, ShouldBeNil) + }) + + Convey("can properly rotate tokens", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + prevToken := token.AuthToken + unhashedPrev := token.UnhashedToken + + refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeFalse) + + updated, err := ctx.markAuthTokenAsSeen(token.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + + getTime = func() time.Time { + return t.Add(time.Hour) + } + + refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + unhashedToken := token.UnhashedToken + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + token.UnhashedToken = unhashedToken + + So(token.RotatedAt, ShouldEqual, getTime().Unix()) + So(token.ClientIp, ShouldEqual, "192.168.10.12") + So(token.UserAgent, ShouldEqual, "a new user agent") + So(token.AuthTokenSeen, ShouldBeFalse) + So(token.SeenAt, ShouldEqual, 0) + So(token.PrevAuthToken, ShouldEqual, prevToken) + + // ability to auth using an old token + + lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + So(lookedUp.SeenAt, ShouldEqual, getTime().Unix()) + + lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.Id, ShouldEqual, token.Id) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(time.Hour + (2 * time.Minute)) + } + + lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + + lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeFalse) + + refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.SeenAt, ShouldEqual, 0) + }) + + Convey("keeps prev token valid for 1 minute after it is confirmed", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + + getTime = func() time.Time { + return t.Add(10 * time.Minute) + } + + prevToken := token.UnhashedToken + refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(20 * time.Minute) + } + + current, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(current, ShouldNotBeNil) + + prev, err := userAuthTokenService.LookupToken(prevToken) + So(err, ShouldBeNil) + So(prev, ShouldNotBeNil) + }) + + Convey("will not mark token unseen when prev and current are the same", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + + lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + + lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + }) + + Convey("Rotate token", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + prevToken := token.AuthToken + + Convey("Should rotate current token and previous token when auth token seen", func() { + updated, err := ctx.markAuthTokenAsSeen(token.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(10 * time.Minute) + } + + refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + storedToken, err := ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(storedToken, ShouldNotBeNil) + So(storedToken.AuthTokenSeen, ShouldBeFalse) + So(storedToken.PrevAuthToken, ShouldEqual, prevToken) + So(storedToken.AuthToken, ShouldNotEqual, prevToken) + + prevToken = storedToken.AuthToken + + updated, err = ctx.markAuthTokenAsSeen(token.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(20 * time.Minute) + } + + refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + storedToken, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(storedToken, ShouldNotBeNil) + So(storedToken.AuthTokenSeen, ShouldBeFalse) + So(storedToken.PrevAuthToken, ShouldEqual, prevToken) + So(storedToken.AuthToken, ShouldNotEqual, prevToken) + }) + + Convey("Should rotate current token, but keep previous token when auth token not seen", func() { + token.RotatedAt = getTime().Add(-2 * time.Minute).Unix() + + getTime = func() time.Time { + return t.Add(2 * time.Minute) + } + + refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + storedToken, err := ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(storedToken, ShouldNotBeNil) + So(storedToken.AuthTokenSeen, ShouldBeFalse) + So(storedToken.PrevAuthToken, ShouldEqual, prevToken) + So(storedToken.AuthToken, ShouldNotEqual, prevToken) + }) + }) + + Reset(func() { + getTime = time.Now + }) + }) +} + +func createTestContext(t *testing.T) *testContext { + t.Helper() + + sqlstore := sqlstore.InitTestDB(t) + tokenService := &UserAuthTokenServiceImpl{ + SQLStore: sqlstore, + Cfg: &setting.Cfg{ + LoginCookieName: "grafana_session", + LoginCookieMaxDays: 7, + LoginDeleteExpiredTokensAfterDays: 30, + LoginCookieRotation: 10, + }, + log: log.New("test-logger"), + } + + UrgentRotateTime = time.Minute + + return &testContext{ + sqlstore: sqlstore, + tokenService: tokenService, + } +} + +type testContext struct { + sqlstore *sqlstore.SqlStore + tokenService *UserAuthTokenServiceImpl +} + +func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) { + sess := c.sqlstore.NewSession() + var t userAuthToken + found, err := sess.ID(id).Get(&t) + if err != nil || !found { + return nil, err + } + + return &t, nil +} + +func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) { + sess := c.sqlstore.NewSession() + res, err := sess.Exec("UPDATE user_auth_token SET auth_token_seen = ? WHERE id = ?", c.sqlstore.Dialect.BooleanStr(true), id) + if err != nil { + return false, err + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return false, err + } + return rowsAffected == 1, nil +} diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go new file mode 100644 index 00000000000..7a0f49539f2 --- /dev/null +++ b/pkg/services/auth/model.go @@ -0,0 +1,25 @@ +package auth + +import ( + "errors" +) + +// Typed errors +var ( + ErrAuthTokenNotFound = errors.New("User auth token not found") +) + +type userAuthToken struct { + Id int64 + UserId int64 + AuthToken string + PrevAuthToken string + UserAgent string + ClientIp string + AuthTokenSeen bool + SeenAt int64 + RotatedAt int64 + CreatedAt int64 + UpdatedAt int64 + UnhashedToken string `xorm:"-"` +} diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/session_cleanup.go new file mode 100644 index 00000000000..7e523181a7b --- /dev/null +++ b/pkg/services/auth/session_cleanup.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "time" +) + +func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { + ticker := time.NewTicker(time.Hour * 12) + deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays) + + for { + select { + case <-ticker.C: + srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() { + srv.deleteOldSession(deleteSessionAfter) + }) + + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) { + sql := `DELETE from user_auth_token WHERE rotated_at < ?` + + deleteBefore := getTime().Add(-deleteSessionAfter) + res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix()) + if err != nil { + return 0, err + } + + affected, err := res.RowsAffected() + srv.log.Info("deleted old sessions", "count", affected) + + return affected, err +} diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/session_cleanup_test.go new file mode 100644 index 00000000000..eef2cd74d04 --- /dev/null +++ b/pkg/services/auth/session_cleanup_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "fmt" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserAuthTokenCleanup(t *testing.T) { + + Convey("Test user auth token cleanup", t, func() { + ctx := createTestContext(t) + + insertToken := func(token string, prev string, rotatedAt int64) { + ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""} + _, err := ctx.sqlstore.NewSession().Insert(&ut) + So(err, ShouldBeNil) + } + + // insert three old tokens that should be deleted + for i := 0; i < 3; i++ { + insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i)) + } + + // insert three active tokens that should not be deleted + for i := 0; i < 3; i++ { + insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix()) + } + + affected, err := ctx.tokenService.deleteOldSession(time.Hour) + So(err, ShouldBeNil) + So(affected, ShouldEqual, 3) + }) +} diff --git a/pkg/services/session/session.go b/pkg/services/session/session.go index 5873a6a5b72..2e60b8a25d7 100644 --- a/pkg/services/session/session.go +++ b/pkg/services/session/session.go @@ -14,8 +14,6 @@ import ( const ( SESS_KEY_USERID = "uid" - SESS_KEY_OAUTH_STATE = "state" - SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys SESS_KEY_LASTLDAPSYNC = "last_ldap_sync" ) diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 36cd8e5ed62..931259ec3ed 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) { addLoginAttemptMigrations(mg) addUserAuthMigrations(mg) addServerlockMigrations(mg) + addUserAuthTokenMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/user_auth_token_mig.go b/pkg/services/sqlstore/migrations/user_auth_token_mig.go new file mode 100644 index 00000000000..9794b7a78c7 --- /dev/null +++ b/pkg/services/sqlstore/migrations/user_auth_token_mig.go @@ -0,0 +1,32 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addUserAuthTokenMigrations(mg *Migrator) { + userAuthTokenV1 := Table{ + Name: "user_auth_token", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "user_id", Type: DB_BigInt, Nullable: false}, + {Name: "auth_token", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "prev_auth_token", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "user_agent", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "client_ip", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "auth_token_seen", Type: DB_Bool, Nullable: false}, + {Name: "seen_at", Type: DB_Int, Nullable: true}, + {Name: "rotated_at", Type: DB_Int, Nullable: false}, + {Name: "created_at", Type: DB_Int, Nullable: false}, + {Name: "updated_at", Type: DB_Int, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"auth_token"}, Type: UniqueIndex}, + {Cols: []string{"prev_auth_token"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create user auth token table", NewAddTableMigration(userAuthTokenV1)) + mg.AddMigration("add unique index user_auth_token.auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[0])) + mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1])) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1e4bc96cd7f..660a00ba41d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -83,9 +83,6 @@ var ( // Security settings. SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string DisableGravatar bool EmailCodeValidMinutes int DataProxyWhiteList map[string]bool @@ -224,6 +221,13 @@ type Cfg struct { EnableAlphaPanels bool DisableSanitizeHtml bool EnterpriseLicensePath string + + LoginCookieName string + LoginCookieMaxDays int + LoginCookieRotation int + LoginDeleteExpiredTokensAfterDays int + + SecurityHTTPSCookies bool } type CommandLineArgs struct { @@ -547,6 +551,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { ApplicationName = APP_NAME_ENTERPRISE } + //login + login := iniFile.Section("login") + cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session") + cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7) + cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30) + cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10) + if cfg.LoginCookieRotation < 2 { + cfg.LoginCookieRotation = 2 + } + Env = iniFile.Section("").Key("app_mode").MustString("development") InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name") PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath) @@ -587,11 +601,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { // read security settings security := iniFile.Section("security") SecretKey = security.Key("secret_key").String() - LogInRememberDays = security.Key("login_remember_days").MustInt() - CookieUserName = security.Key("cookie_username").String() - CookieRememberName = security.Key("cookie_remember_name").String() DisableGravatar = security.Key("disable_gravatar").MustBool(true) cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) + cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false) DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection // read snapshots settings diff --git a/pkg/util/encoding.go b/pkg/util/encoding.go index 0edb721e422..e82344d73f9 100644 --- a/pkg/util/encoding.go +++ b/pkg/util/encoding.go @@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) { return userAndPass[0], userAndPass[1], nil } + +func RandomHex(n int) (string, error) { + bytes := make([]byte, n) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/pkg/util/ip_address.go b/pkg/util/ip_address.go new file mode 100644 index 00000000000..d8d95ef3acd --- /dev/null +++ b/pkg/util/ip_address.go @@ -0,0 +1,29 @@ +package util + +import ( + "net" + "strings" +) + +// ParseIPAddress parses an IP address and removes port and/or IPV6 format +func ParseIPAddress(input string) string { + s := input + lastIndex := strings.LastIndex(input, ":") + + if lastIndex != -1 { + if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" { + s = input[:lastIndex] + } + } + + s = strings.Replace(s, "[", "", -1) + s = strings.Replace(s, "]", "", -1) + + ip := net.ParseIP(s) + + if ip.IsLoopback() { + return "127.0.0.1" + } + + return ip.String() +} diff --git a/pkg/util/ip_address_test.go b/pkg/util/ip_address_test.go new file mode 100644 index 00000000000..fd3e3ea8587 --- /dev/null +++ b/pkg/util/ip_address_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestParseIPAddress(t *testing.T) { + Convey("Test parse ip address", t, func() { + So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140") + So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1") + So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1") + So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140") + }) +}