Auth: Implement Token URL JWT Auth (#52662)

* Auth: check of auth_token in url and resolve user if present

* check if auth_token is passed in url

* Auth: Pass auth_token for request if present in path

* no need to decode token in index

* temp

* use loadURLToken and set authorization header

* cache token in memory and strip it from url

* Use loadURLToken

* Keep token in url

* strip sensitive query strings from url used by context logger

* adapt login by url to jwt token

* add jwt iframe devenv

* add jwt iframe devenv instructions

* add access note

* add test for cleaning request

* ensure jwt token is not carried into handlers

* do not reshuffle queries, might be important

* add correct db dump location

* prefer set token instead of cached token

Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>

Co-authored-by: Karl Persson <kalle.persson@grafana.com>
Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
Jo 2022-07-27 14:10:47 +00:00 committed by GitHub
parent 7ba076de10
commit c2d3c90bc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 138 additions and 5 deletions

View File

@ -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
\.

View File

@ -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:

View File

@ -52,6 +52,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
helpEnabled = false;
profileEnabled = false;
ldapEnabled = false;
jwtHeaderName = '';
jwtUrlLogin = false;
sigV4AuthEnabled = false;
samlEnabled = false;
samlName = '';

View File

@ -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,

View File

@ -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()
}

View File

@ -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)
})
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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", "{}")

View File

@ -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<T>(options);
const failureStream = fromFetchStream.pipe(this.toFailureStream<T>(options));
const successStream = fromFetchStream.pipe(

View File

@ -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;
};

View File

@ -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,
});