diff --git a/devenv/docker/blocks/jwt_proxy/cloak.sql b/devenv/docker/blocks/jwt_proxy/cloak.sql index ac0cfde1d44..88194e107a0 100644 --- a/devenv/docker/blocks/jwt_proxy/cloak.sql +++ b/devenv/docker/blocks/jwt_proxy/cloak.sql @@ -1688,6 +1688,7 @@ a5a8fed6-0bca-4646-9946-2fe84175353b t f account 0 f d0b8b6b6-2a02-412c-84d1-716 805aebc8-9d01-42b6-bcce-6ce48ca63ef0 t f security-admin-console 0 t 27d2217e-9934-4971-93b8-77969e47ecf7 /admin/grafana/console/ f \N f grafana openid-connect 0 f f ${client_security-admin-console} f client-secret ${authAdminUrl} \N \N t f f f 6bd2d943-9800-4839-9ddc-03c04930cd9f t f admin-cli 0 t da0811c3-5031-4f35-9dc5-441050461a37 \N f \N f grafana openid-connect 0 f f ${client_admin-cli} f client-secret \N \N \N f f t f 09b79548-8426-4c0e-8e0b-7488467532c7 t t grafana-oauth 0 f d17b9ea9-bcb1-43d2-b132-d339e55872a8 http://127.0.0.1:8087 f http://127.0.0.1:8087 f grafana openid-connect -1 f f \N f client-secret http://127.0.0.1:8087 \N \N t f t f +169f1dea-80f0-4a99-8509-9abb70ab0a5c t t sample-iframe-project 0 t c2ada58a-760e-40d7-8ddc-9ea69b465af2 \N f http://localhost:4200 f grafana openid-connect -1 f f \N f client-secret http://localhost:4200 \N \N t f t f \. @@ -1717,6 +1718,22 @@ COPY public.client_attributes (client_id, value, name) FROM stdin; 09b79548-8426-4c0e-8e0b-7488467532c7 false client_credentials.use_refresh_token 09b79548-8426-4c0e-8e0b-7488467532c7 false display.on.consent.screen 09b79548-8426-4c0e-8e0b-7488467532c7 backchannel.logout.url +169f1dea-80f0-4a99-8509-9abb70ab0a5c true backchannel.logout.session.required +169f1dea-80f0-4a99-8509-9abb70ab0a5c false backchannel.logout.revoke.offline.tokens +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.server.signature +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.server.signature.keyinfo.ext +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.assertion.signature +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.client.signature +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.encrypt +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.authnstatement +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.onetimeuse.condition +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml_force_name_id_format +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.multivalued.roles +169f1dea-80f0-4a99-8509-9abb70ab0a5c false saml.force.post.binding +169f1dea-80f0-4a99-8509-9abb70ab0a5c false exclude.session.state.from.auth.response +169f1dea-80f0-4a99-8509-9abb70ab0a5c false tls.client.certificate.bound.access.tokens +169f1dea-80f0-4a99-8509-9abb70ab0a5c false client_credentials.use_refresh_token +169f1dea-80f0-4a99-8509-9abb70ab0a5c false display.on.consent.screen \. @@ -1975,6 +1992,14 @@ a8698f4f-5fa1-4baa-be05-87d03052af49 c61f5b19-c17e-49a1-91b8-a0296411b928 f 09b79548-8426-4c0e-8e0b-7488467532c7 d4723cd4-f717-44b7-a9b0-6c32c5ecd23f t 09b79548-8426-4c0e-8e0b-7488467532c7 0a7c7dde-23d7-4a93-bdee-4a8963aee9a4 t 09b79548-8426-4c0e-8e0b-7488467532c7 74daf2cd-40d4-4304-87a8-92cdca808512 t +169f1dea-80f0-4a99-8509-9abb70ab0a5c d6077ed7-b265-4f82-9336-24614967bd5d t +169f1dea-80f0-4a99-8509-9abb70ab0a5c 74daf2cd-40d4-4304-87a8-92cdca808512 t +169f1dea-80f0-4a99-8509-9abb70ab0a5c 96d521d3-facc-4b5a-a8b4-a879bae6be07 t +169f1dea-80f0-4a99-8509-9abb70ab0a5c 699671ab-e7c1-4fcf-beb8-ea54f1471fc1 t +169f1dea-80f0-4a99-8509-9abb70ab0a5c 0e98d5f9-d3f7-4b1d-9791-d442524fc2ab f +169f1dea-80f0-4a99-8509-9abb70ab0a5c a5bb3a5f-fd26-4be6-9557-26e20a03d33d f +169f1dea-80f0-4a99-8509-9abb70ab0a5c d6ffe9fc-a03c-4496-85dc-dbb5e7754587 f +169f1dea-80f0-4a99-8509-9abb70ab0a5c c61f5b19-c17e-49a1-91b8-a0296411b928 f \. @@ -3135,6 +3160,7 @@ a5a8fed6-0bca-4646-9946-2fe84175353b /realms/grafana/account/* 230081b5-9161-45c3-9e08-9eda5412f7f7 /realms/grafana/account/* 805aebc8-9d01-42b6-bcce-6ce48ca63ef0 /admin/grafana/console/* 09b79548-8426-4c0e-8e0b-7488467532c7 http://127.0.0.1:8088/oauth2/callback +169f1dea-80f0-4a99-8509-9abb70ab0a5c http://localhost:4200/* \. @@ -3410,6 +3436,7 @@ COPY public.web_origins (client_id, value) FROM stdin; 2f521d09-7304-4b5e-a94b-7cc7300b8b50 + 805aebc8-9d01-42b6-bcce-6ce48ca63ef0 + 09b79548-8426-4c0e-8e0b-7488467532c7 http://127.0.0.1:8087 +169f1dea-80f0-4a99-8509-9abb70ab0a5c http://localhost:4200 \. diff --git a/devenv/docker/blocks/jwt_proxy/readme.md b/devenv/docker/blocks/jwt_proxy/readme.md index 6aa7d713793..5f3b30018ce 100644 --- a/devenv/docker/blocks/jwt_proxy/readme.md +++ b/devenv/docker/blocks/jwt_proxy/readme.md @@ -31,13 +31,34 @@ Access Grafana through: http://127.0.0.1:8088 ``` +## Devenv setup jwt auth iframe embedding + +- Add previous configuration and next snippet to grafana.ini + +```ini +[security] +allow_embedding = true +``` + +- Create dashboard and copy UID + +- Clone [https://github.com/grafana/grafana-iframe-oauth-sample](https://github.com/grafana/grafana-iframe-oauth-sample) + +- Change the dashboard URL in `grafana-iframe-oauth-sample/src/pages/restricted.tsx` to use the dashboard you created (keep URL query values) + +- Start sample app from the `grafana-iframe-oauth-sample` folder with: `yarn start` + +- Navigate to [http://localhost:4200](http://localhost:4200) and press restricted area + +Note: You may need to grant the JWT user in grafana access to the datasources and the dashboard + ## Backing up keycloak DB In case you want to make changes to the devenv setup, you can dump keycloack's DB: ```bash cd devenv; -docker-compose exec -T oauthkeycloakdb bash -c "pg_dump -U keycloak keycloak" > docker/blocks/oauth/cloak.sql +docker-compose exec -T oauthkeycloakdb bash -c "pg_dump -U keycloak keycloak" > docker/blocks/jwt_proxy/cloak.sql ``` ## Connecting to keycloack: diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 76da13d75bc..f33a89bc0b4 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -52,6 +52,8 @@ export class GrafanaBootConfig implements GrafanaConfig { helpEnabled = false; profileEnabled = false; ldapEnabled = false; + jwtHeaderName = ''; + jwtUrlLogin = false; sigV4AuthEnabled = false; samlEnabled = false; samlName = ''; diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index caa75f6b543..2bd49b3ee33 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -98,6 +98,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, "authProxyEnabled": setting.AuthProxyEnabled, "ldapEnabled": hs.Cfg.LDAPEnabled, + "jwtHeaderName": hs.Cfg.JWTAuthHeaderName, + "jwtUrlLogin": hs.Cfg.JWTAuthURLLogin, "alertingEnabled": setting.AlertingEnabled, "alertingErrorOrTimeout": setting.AlertingErrorOrTimeout, "alertingNoDataOrNullValues": setting.AlertingNoDataOrNullValues, diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 6e5acd0ea40..b5f75fe59ef 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -57,7 +57,7 @@ func Logger(cfg *setting.Cfg) web.Handler { "time_ms", int64(timeTaken), "duration", duration, "size", rw.Size(), - "referer", sanitizeURL(ctx, req.Referer()), + "referer", SanitizeURL(ctx, req.Referer()), } traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false) @@ -74,7 +74,11 @@ func Logger(cfg *setting.Cfg) web.Handler { } } -func sanitizeURL(ctx *models.ReqContext, s string) string { +var sensitiveQueryStrings = [...]string{ + "auth_token", +} + +func SanitizeURL(ctx *models.ReqContext, s string) string { if s == "" { return s } @@ -84,5 +88,13 @@ func sanitizeURL(ctx *models.ReqContext, s string) string { ctx.Logger.Warn("Received invalid referer in request headers, removed for log forgery prevention") return "" } + + // strip out sensitive query strings + values := u.Query() + for _, query := range sensitiveQueryStrings { + values.Del(query) + } + u.RawQuery = values.Encode() + return u.String() } diff --git a/pkg/middleware/logger_test.go b/pkg/middleware/logger_test.go index e1e6459a0ce..1b74bb7246c 100644 --- a/pkg/middleware/logger_test.go +++ b/pkg/middleware/logger_test.go @@ -51,7 +51,7 @@ func Test_sanitizeURL(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, sanitizeURL(tt.args.ctx, tt.args.s), "sanitizeURL(%v, %v)", tt.args.ctx, tt.args.s) + assert.Equalf(t, tt.want, SanitizeURL(tt.args.ctx, tt.args.s), "sanitizeURL(%v, %v)", tt.args.ctx, tt.args.s) }) } } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index a89af3a89d2..eb5c46a4714 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -493,6 +493,36 @@ func TestMiddlewareContext(t *testing.T) { sc.exec() }) + middlewareScenario(t, "Request body should not be read in default context handler, but query should be altered - jwt", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("POST", "/?targetOrgId=123&auth_token=token") + body := "key=value" + sc.req.Body = io.NopCloser(strings.NewReader(body)) + + sc.handlerFunc = func(c *models.ReqContext) { + t.Log("Handler called") + defer func() { + err := c.Req.Body.Close() + require.NoError(t, err) + }() + + require.Equal(t, "", c.Req.URL.Query().Get("auth_token")) + + bodyAfterHandler, e := io.ReadAll(c.Req.Body) + require.NoError(t, e) + require.Equal(t, body, string(bodyAfterHandler)) + } + + sc.req.Header.Set(sc.cfg.AuthProxyHeaderName, hdrName) + sc.req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + sc.req.Header.Set("Content-Length", strconv.Itoa(len(body))) + sc.m.Post("/", sc.defaultHandler) + sc.exec() + }, func(cfg *setting.Cfg) { + cfg.JWTAuthEnabled = true + cfg.JWTAuthURLLogin = true + cfg.JWTAuthHeaderName = "X-WEBAUTH-TOKEN" + }) + middlewareScenario(t, "Should get an existing user from header", func(t *testing.T, sc *scenarioContext) { const userID int64 = 12 const orgID int64 = 2 diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index f1b847b3814..4aca8143ac1 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -18,6 +18,10 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) } jwtToken := ctx.Req.Header.Get(h.Cfg.JWTAuthHeaderName) + if jwtToken == "" && h.Cfg.JWTAuthURLLogin { + jwtToken = ctx.Req.URL.Query().Get("auth_token") + } + if jwtToken == "" { return false } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index b463b14db26..41e7b75d790 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -316,6 +316,7 @@ type Cfg struct { // JWT Auth JWTAuthEnabled bool JWTAuthHeaderName string + JWTAuthURLLogin bool JWTAuthEmailClaim string JWTAuthUsernameClaim string JWTAuthExpectClaims string @@ -1305,6 +1306,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { authJWT := iniFile.Section("auth.jwt") cfg.JWTAuthEnabled = authJWT.Key("enabled").MustBool(false) cfg.JWTAuthHeaderName = valueAsString(authJWT, "header_name", "") + cfg.JWTAuthURLLogin = authJWT.Key("url_login").MustBool(false) cfg.JWTAuthEmailClaim = valueAsString(authJWT, "email_claim", "") cfg.JWTAuthUsernameClaim = valueAsString(authJWT, "username_claim", "") cfg.JWTAuthExpectClaims = valueAsString(authJWT, "expect_claims", "{}") diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 6e8f20cdf6b..061bb83ebb7 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -17,6 +17,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data'; import { BackendSrv as BackendService, BackendSrvRequest, config, FetchError, FetchResponse } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { getConfig } from 'app/core/config'; +import { loadUrlToken } from 'app/core/utils/urlToken'; import { DashboardSearchHit } from 'app/features/search/types'; import { getGrafanaStorage } from 'app/features/storage/storage'; import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal'; @@ -128,6 +129,17 @@ export class BackendSrv implements BackendService { options = this.parseRequestOptions(options); + const token = loadUrlToken(); + if (token !== null && token !== '') { + if (!options.headers) { + options.headers = {}; + } + + if (config.jwtUrlLogin && config.jwtHeaderName) { + options.headers[config.jwtHeaderName] = `${token}`; + } + } + const fromFetchStream = this.getFromFetchStream(options); const failureStream = fromFetchStream.pipe(this.toFailureStream(options)); const successStream = fromFetchStream.pipe( diff --git a/public/app/core/utils/urlToken.ts b/public/app/core/utils/urlToken.ts new file mode 100644 index 00000000000..d475e8cd178 --- /dev/null +++ b/public/app/core/utils/urlToken.ts @@ -0,0 +1,13 @@ +let cachedToken = ''; + +export const loadUrlToken = (): string | null => { + const params = new URLSearchParams(window.location.search); + + const token = params.get('auth_token'); + if (token !== null && token !== '') { + cachedToken = token; + return token; + } + + return cachedToken; +}; diff --git a/public/app/features/live/centrifuge/service.ts b/public/app/features/live/centrifuge/service.ts index 3cb95ff6ca6..1bf1e8056d0 100644 --- a/public/app/features/live/centrifuge/service.ts +++ b/public/app/features/live/centrifuge/service.ts @@ -18,6 +18,7 @@ import { StreamingFrameOptions, } from '@grafana/runtime/src/services/live'; import { BackendDataSourceResponse } from '@grafana/runtime/src/utils/queryResponse'; +import { loadUrlToken } from 'app/core/utils/urlToken'; import { StreamingResponseData } from '../data/utils'; @@ -66,7 +67,14 @@ export class CentrifugeService implements CentrifugeSrv { constructor(private deps: CentrifugeSrvDeps) { this.dataStreamSubscriberReadiness = deps.dataStreamSubscriberReadiness.pipe(share(), startWith(true)); - const liveUrl = `${deps.appUrl.replace(/^http/, 'ws')}/api/live/ws`; + + let liveUrl = `${deps.appUrl.replace(/^http/, 'ws')}/api/live/ws`; + + const token = loadUrlToken(); + if (token !== null && token !== '') { + liveUrl += '?auth_token=' + token; + } + this.centrifuge = new Centrifuge(liveUrl, { timeout: 30000, });