Commit d5c1f8e5 by Carl Bergquist Committed by GitHub

Merge pull request #14912 from grafana/poc_token_auth

WIP. POC for session replacement base hashed tokens. 
parents 4b68055c e4924795
......@@ -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
......
......@@ -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
......
......@@ -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:
......
# 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
```
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) => {}
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));
}
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;
}
#/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 "$@"
......@@ -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) {
......
......@@ -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) {}
......@@ -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
......
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
}
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
func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
if c.IsSignedIn || c.IsAnonymous {
return JSON(200, "Logged in")
}
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
}
......@@ -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")
}
......@@ -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()
......
......@@ -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)
......
......@@ -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)
......
......@@ -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
......
......@@ -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 == "" {
......
......@@ -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) {}
......@@ -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"
)
......
......@@ -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
......
......@@ -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,
......
......@@ -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())
......
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())
}
}
}
......@@ -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
......
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[:])
}
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:"-"`
}
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
}
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)
})
}
......@@ -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"
)
......
......@@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
addLoginAttemptMigrations(mg)
addUserAuthMigrations(mg)
addServerlockMigrations(mg)
addUserAuthTokenMigrations(mg)
}
func addMigrationLogMigrations(mg *Migrator) {
......
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]))
}
......@@ -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
......
......@@ -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
}
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()
}
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")
})
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment