mirror of https://github.com/grafana/grafana
commit
7a8eb8c115
@ -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 |
||||
``` |
@ -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) => {} |
@ -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)); |
||||
} |
@ -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; |
||||
} |
@ -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 "$@" |
@ -0,0 +1,81 @@ |
||||
import { getMappedValue } from './valueMappings'; |
||||
import { ValueMapping, MappingType } from '../types/panel'; |
||||
|
||||
describe('Format value with value mappings', () => { |
||||
it('should return undefined with no valuemappings', () => { |
||||
const valueMappings: ValueMapping[] = []; |
||||
const value = '10'; |
||||
|
||||
expect(getMappedValue(valueMappings, value)).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined with no matching valuemappings', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, |
||||
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, |
||||
]; |
||||
const value = '10'; |
||||
|
||||
expect(getMappedValue(valueMappings, value)).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return first matching mapping with lowest id', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, |
||||
{ id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' }, |
||||
]; |
||||
const value = '10'; |
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); |
||||
}); |
||||
|
||||
it('should return if value is null and value to text mapping value is null', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, |
||||
{ id: 1, operator: '', text: '<NULL>', type: MappingType.ValueToText, value: 'null' }, |
||||
]; |
||||
const value = null; |
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>'); |
||||
}); |
||||
|
||||
it('should return if value is null and range to text mapping from and to is null', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: '<NULL>', type: MappingType.RangeToText, from: 'null', to: 'null' }, |
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, |
||||
]; |
||||
const value = null; |
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>'); |
||||
}); |
||||
|
||||
it('should return rangeToText mapping where value equals to', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' }, |
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, |
||||
]; |
||||
const value = '10'; |
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-10'); |
||||
}); |
||||
|
||||
it('should return rangeToText mapping where value equals from', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' }, |
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, |
||||
]; |
||||
const value = '10'; |
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('10-20'); |
||||
}); |
||||
|
||||
it('should return rangeToText mapping where value is between from and to', () => { |
||||
const valueMappings: ValueMapping[] = [ |
||||
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, |
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, |
||||
]; |
||||
const value = '10'; |
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); |
||||
}); |
||||
}); |
@ -0,0 +1,89 @@ |
||||
import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types'; |
||||
|
||||
export type TimeSeriesValue = string | number | null; |
||||
|
||||
const addValueToTextMappingText = ( |
||||
allValueMappings: ValueMapping[], |
||||
valueToTextMapping: ValueMap, |
||||
value: TimeSeriesValue |
||||
) => { |
||||
if (valueToTextMapping.value === undefined) { |
||||
return allValueMappings; |
||||
} |
||||
|
||||
if (value === null && valueToTextMapping.value && valueToTextMapping.value.toLowerCase() === 'null') { |
||||
return allValueMappings.concat(valueToTextMapping); |
||||
} |
||||
|
||||
const valueAsNumber = parseFloat(value as string); |
||||
const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); |
||||
|
||||
if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) { |
||||
return allValueMappings; |
||||
} |
||||
|
||||
if (valueAsNumber !== valueToTextMappingAsNumber) { |
||||
return allValueMappings; |
||||
} |
||||
|
||||
return allValueMappings.concat(valueToTextMapping); |
||||
}; |
||||
|
||||
const addRangeToTextMappingText = ( |
||||
allValueMappings: ValueMapping[], |
||||
rangeToTextMapping: RangeMap, |
||||
value: TimeSeriesValue |
||||
) => { |
||||
if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) { |
||||
return allValueMappings; |
||||
} |
||||
|
||||
if ( |
||||
value === null && |
||||
rangeToTextMapping.from && |
||||
rangeToTextMapping.to && |
||||
rangeToTextMapping.from.toLowerCase() === 'null' && |
||||
rangeToTextMapping.to.toLowerCase() === 'null' |
||||
) { |
||||
return allValueMappings.concat(rangeToTextMapping); |
||||
} |
||||
|
||||
const valueAsNumber = parseFloat(value as string); |
||||
const fromAsNumber = parseFloat(rangeToTextMapping.from as string); |
||||
const toAsNumber = parseFloat(rangeToTextMapping.to as string); |
||||
|
||||
if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) { |
||||
return allValueMappings; |
||||
} |
||||
|
||||
if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) { |
||||
return allValueMappings.concat(rangeToTextMapping); |
||||
} |
||||
|
||||
return allValueMappings; |
||||
}; |
||||
|
||||
const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => { |
||||
const allFormattedValueMappings = valueMappings.reduce( |
||||
(allValueMappings, valueMapping) => { |
||||
if (valueMapping.type === MappingType.ValueToText) { |
||||
allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value); |
||||
} else if (valueMapping.type === MappingType.RangeToText) { |
||||
allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value); |
||||
} |
||||
|
||||
return allValueMappings; |
||||
}, |
||||
[] as ValueMapping[] |
||||
); |
||||
|
||||
allFormattedValueMappings.sort((t1, t2) => { |
||||
return t1.id - t2.id; |
||||
}); |
||||
|
||||
return allFormattedValueMappings; |
||||
}; |
||||
|
||||
export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => { |
||||
return getAllFormattedValueMappings(valueMappings, value)[0]; |
||||
}; |
@ -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()) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,148 @@ |
||||
// +build integration
|
||||
|
||||
package alerting |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"net" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"testing" |
||||
"time" |
||||
|
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestEngineTimeouts(t *testing.T) { |
||||
Convey("Alerting engine timeout tests", t, func() { |
||||
engine := NewEngine() |
||||
engine.resultHandler = &FakeResultHandler{} |
||||
job := &Job{Running: true, Rule: &Rule{}} |
||||
|
||||
Convey("Should trigger as many retries as needed", func() { |
||||
Convey("pended alert for datasource -> result handler should be worked", func() { |
||||
// reduce alert timeout to test quickly
|
||||
originAlertTimeout := alertTimeout |
||||
alertTimeout = 2 * time.Second |
||||
transportTimeoutInterval := 2 * time.Second |
||||
serverBusySleepDuration := 1 * time.Second |
||||
|
||||
evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) |
||||
resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) |
||||
engine.evalHandler = evalHandler |
||||
engine.resultHandler = resultHandler |
||||
|
||||
engine.processJobWithRetry(context.TODO(), job) |
||||
|
||||
So(evalHandler.EvalSucceed, ShouldEqual, true) |
||||
So(resultHandler.ResultHandleSucceed, ShouldEqual, true) |
||||
|
||||
// initialize for other tests.
|
||||
alertTimeout = originAlertTimeout |
||||
engine.resultHandler = &FakeResultHandler{} |
||||
}) |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
type FakeCommonTimeoutHandler struct { |
||||
TransportTimeoutDuration time.Duration |
||||
ServerBusySleepDuration time.Duration |
||||
EvalSucceed bool |
||||
ResultHandleSucceed bool |
||||
} |
||||
|
||||
func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler { |
||||
return &FakeCommonTimeoutHandler{ |
||||
TransportTimeoutDuration: transportTimeoutDuration, |
||||
ServerBusySleepDuration: serverBusySleepDuration, |
||||
EvalSucceed: false, |
||||
ResultHandleSucceed: false, |
||||
} |
||||
} |
||||
|
||||
func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) { |
||||
// 1. prepare mock server
|
||||
path := "/evaltimeout" |
||||
srv := runBusyServer(path, handler.ServerBusySleepDuration) |
||||
defer srv.Close() |
||||
|
||||
// 2. send requests
|
||||
url := srv.URL + path |
||||
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration) |
||||
if res != nil { |
||||
defer res.Body.Close() |
||||
} |
||||
|
||||
if err != nil { |
||||
evalContext.Error = errors.New("Fake evaluation timeout test failure") |
||||
return |
||||
} |
||||
|
||||
if res.StatusCode == 200 { |
||||
handler.EvalSucceed = true |
||||
} |
||||
|
||||
evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response") |
||||
} |
||||
|
||||
func (handler *FakeCommonTimeoutHandler) Handle(evalContext *EvalContext) error { |
||||
// 1. prepare mock server
|
||||
path := "/resulthandle" |
||||
srv := runBusyServer(path, handler.ServerBusySleepDuration) |
||||
defer srv.Close() |
||||
|
||||
// 2. send requests
|
||||
url := srv.URL + path |
||||
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration) |
||||
if res != nil { |
||||
defer res.Body.Close() |
||||
} |
||||
|
||||
if err != nil { |
||||
evalContext.Error = errors.New("Fake result handle timeout test failure") |
||||
return evalContext.Error |
||||
} |
||||
|
||||
if res.StatusCode == 200 { |
||||
handler.ResultHandleSucceed = true |
||||
return nil |
||||
} |
||||
|
||||
evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response") |
||||
|
||||
return evalContext.Error |
||||
} |
||||
|
||||
func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server { |
||||
mux := http.NewServeMux() |
||||
server := httptest.NewServer(mux) |
||||
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { |
||||
time.Sleep(serverBusySleepDuration) |
||||
}) |
||||
|
||||
return server |
||||
} |
||||
|
||||
func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) { |
||||
req, err := http.NewRequest("GET", url, nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
req = req.WithContext(context) |
||||
|
||||
transport := http.Transport{ |
||||
Dial: (&net.Dialer{ |
||||
Timeout: transportTimeoutInterval, |
||||
KeepAlive: transportTimeoutInterval, |
||||
}).Dial, |
||||
} |
||||
client := http.Client{ |
||||
Transport: &transport, |
||||
} |
||||
|
||||
return client.Do(req) |
||||
} |
@ -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[:]) |
||||
} |
@ -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 |
||||
} |
@ -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:"-"` |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
}) |
||||
} |
@ -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])) |
||||
} |
@ -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() |
||||
} |
@ -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") |
||||
}) |
||||
} |
@ -1,13 +1,17 @@ |
||||
import { LocationUpdate } from 'app/types'; |
||||
|
||||
export enum CoreActionTypes { |
||||
UpdateLocation = 'UPDATE_LOCATION', |
||||
} |
||||
|
||||
export type Action = UpdateLocationAction; |
||||
|
||||
export interface UpdateLocationAction { |
||||
type: 'UPDATE_LOCATION'; |
||||
type: CoreActionTypes.UpdateLocation; |
||||
payload: LocationUpdate; |
||||
} |
||||
|
||||
export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({ |
||||
type: 'UPDATE_LOCATION', |
||||
type: CoreActionTypes.UpdateLocation, |
||||
payload: location, |
||||
}); |
||||
|
@ -1,71 +0,0 @@ |
||||
import angular from 'angular'; |
||||
import _ from 'lodash'; |
||||
import $ from 'jquery'; |
||||
import coreModule from '../core_module'; |
||||
|
||||
export class InspectCtrl { |
||||
/** @ngInject */ |
||||
constructor($scope, $sanitize) { |
||||
const model = $scope.inspector; |
||||
|
||||
$scope.init = function() { |
||||
$scope.editor = { index: 0 }; |
||||
|
||||
if (!model.error) { |
||||
return; |
||||
} |
||||
|
||||
if (_.isString(model.error.data)) { |
||||
$scope.response = $('<div>' + model.error.data + '</div>').text(); |
||||
} else if (model.error.data) { |
||||
if (model.error.data.response) { |
||||
$scope.response = $sanitize(model.error.data.response); |
||||
} else { |
||||
$scope.response = angular.toJson(model.error.data, true); |
||||
} |
||||
} else if (model.error.message) { |
||||
$scope.message = model.error.message; |
||||
} |
||||
|
||||
if (model.error.config && model.error.config.params) { |
||||
$scope.request_parameters = _.map(model.error.config.params, (value, key) => { |
||||
return { key: key, value: value }; |
||||
}); |
||||
} |
||||
|
||||
if (model.error.stack) { |
||||
$scope.editor.index = 3; |
||||
$scope.stack_trace = model.error.stack; |
||||
$scope.message = model.error.message; |
||||
} |
||||
|
||||
if (model.error.config && model.error.config.data) { |
||||
$scope.editor.index = 2; |
||||
|
||||
if (_.isString(model.error.config.data)) { |
||||
$scope.request_parameters = this.getParametersFromQueryString(model.error.config.data); |
||||
} else { |
||||
$scope.request_parameters = _.map(model.error.config.data, (value, key) => { |
||||
return { key: key, value: angular.toJson(value, true) }; |
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
getParametersFromQueryString(queryString) { |
||||
const result = []; |
||||
const parameters = queryString.split('&'); |
||||
for (let i = 0; i < parameters.length; i++) { |
||||
const keyValue = parameters[i].split('='); |
||||
if (keyValue[1].length > 0) { |
||||
result.push({ |
||||
key: keyValue[0], |
||||
value: (window as any).unescape(keyValue[1]), |
||||
}); |
||||
} |
||||
} |
||||
return result; |
||||
} |
||||
} |
||||
|
||||
coreModule.controller('InspectCtrl', InspectCtrl); |
@ -1,13 +0,0 @@ |
||||
import coreModule from 'app/core/core_module'; |
||||
|
||||
export class AlertingSrv { |
||||
dashboard: any; |
||||
alerts: any[]; |
||||
|
||||
init(dashboard, alerts) { |
||||
this.dashboard = dashboard; |
||||
this.alerts = alerts || []; |
||||
} |
||||
} |
||||
|
||||
coreModule.service('alertingSrv', AlertingSrv); |
@ -1,45 +0,0 @@ |
||||
import './dashboard_ctrl'; |
||||
import './alerting_srv'; |
||||
import './history/history'; |
||||
import './dashboard_loader_srv'; |
||||
import './dashnav/dashnav'; |
||||
import './submenu/submenu'; |
||||
import './save_as_modal'; |
||||
import './save_modal'; |
||||
import './save_provisioned_modal'; |
||||
import './shareModalCtrl'; |
||||
import './share_snapshot_ctrl'; |
||||
import './dashboard_srv'; |
||||
import './view_state_srv'; |
||||
import './validation_srv'; |
||||
import './time_srv'; |
||||
import './unsaved_changes_srv'; |
||||
import './unsaved_changes_modal'; |
||||
import './timepicker/timepicker'; |
||||
import './upload'; |
||||
import './export/export_modal'; |
||||
import './export_data/export_data_modal'; |
||||
import './ad_hoc_filters'; |
||||
import './repeat_option/repeat_option'; |
||||
import './dashgrid/DashboardGridDirective'; |
||||
import './dashgrid/RowOptions'; |
||||
import './folder_picker/folder_picker'; |
||||
import './move_to_folder_modal/move_to_folder'; |
||||
import './settings/settings'; |
||||
import './panellinks/module'; |
||||
import './dashlinks/module'; |
||||
|
||||
// angular wrappers
|
||||
import { react2AngularDirective } from 'app/core/utils/react2angular'; |
||||
import DashboardPermissions from './permissions/DashboardPermissions'; |
||||
|
||||
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']); |
||||
|
||||
import coreModule from 'app/core/core_module'; |
||||
import { FolderDashboardsCtrl } from './folder_dashboards_ctrl'; |
||||
import { DashboardImportCtrl } from './dashboard_import_ctrl'; |
||||
import { CreateFolderCtrl } from './create_folder_ctrl'; |
||||
|
||||
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl); |
||||
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl); |
||||
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl); |
@ -0,0 +1 @@ |
||||
export { AdHocFiltersCtrl } from './AdHocFiltersCtrl'; |
@ -0,0 +1 @@ |
||||
export { AddPanelWidget } from './AddPanelWidget'; |
@ -1,6 +1,6 @@ |
||||
import config from 'app/core/config'; |
||||
import _ from 'lodash'; |
||||
import { DashboardModel } from '../dashboard_model'; |
||||
import { DashboardModel } from '../../dashboard_model'; |
||||
|
||||
export class DashboardExporter { |
||||
constructor(private datasourceSrv) {} |
@ -0,0 +1,2 @@ |
||||
export { DashboardExporter } from './DashboardExporter'; |
||||
export { DashExportCtrl } from './DashExportCtrl'; |
@ -1,6 +1,6 @@ |
||||
import angular from 'angular'; |
||||
import _ from 'lodash'; |
||||
import { iconMap } from './editor'; |
||||
import { iconMap } from './DashLinksEditorCtrl'; |
||||
|
||||
function dashLinksContainer() { |
||||
return { |
@ -0,0 +1,2 @@ |
||||
export { DashLinksContainerCtrl } from './DashLinksContainerCtrl'; |
||||
export { DashLinksEditorCtrl } from './DashLinksEditorCtrl'; |
@ -0,0 +1 @@ |
||||
export { DashNavCtrl } from './DashNavCtrl'; |
@ -0,0 +1 @@ |
||||
export { SettingsCtrl } from './SettingsCtrl'; |
@ -0,0 +1 @@ |
||||
export { ExportDataModalCtrl } from './ExportDataModalCtrl'; |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue