mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 08:56:43 -06:00
Auth: Add feature flag to move token rotation to client (#65060)
* FeatureToggle: Add toggle to use a new way of rotating tokens * API: Add endpoints to perform token rotation, one endpoint for api request and one endpoint for redirectsd * Auth: Aling not authorized handling between auth middleware and access control middleware * API: add utility function to get redirect for login * API: Handle token rotation redirect for login page * Frontend: Add job scheduling for token rotation and make call to token rotation as fallback in retry request * ContextHandler: Prevent in-request rotation if feature flag is enabled and check if token needs to be rotated * AuthN: Prevent in-request rotation if feature flag is enabled and check if token needs to be rotated * Cookies: Add option NotHttpOnly * AuthToken: Add helper function to get next rotation time and another function to check if token need to be rotated * AuthN: Add function to delete session cookie and set expiry cookie Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
parent
d13488a435
commit
382b24742a
@ -94,6 +94,7 @@ Alpha features might be changed or removed without prior notice.
|
||||
| `prometheusMetricEncyclopedia` | Replaces the Prometheus query builder metric select option with a paginated and filterable component |
|
||||
| `timeSeriesTable` | Enable time series table transformer & sparkline cell type |
|
||||
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy |
|
||||
| `clientTokenRotation` | Replaces the current in-request token rotation so that the client initiates the rotation |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
@ -82,4 +82,5 @@ export interface FeatureToggles {
|
||||
prometheusMetricEncyclopedia?: boolean;
|
||||
timeSeriesTable?: boolean;
|
||||
influxdbBackendMigration?: boolean;
|
||||
clientTokenRotation?: boolean;
|
||||
}
|
||||
|
@ -218,6 +218,11 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/swagger-ui", swaggerUI)
|
||||
r.Get("/openapi3", openapi3)
|
||||
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
r.Post("/api/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthToken))
|
||||
r.Get("/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthTokenRedirect))
|
||||
}
|
||||
|
||||
// authed api
|
||||
r.Group("/api", func(apiRoute routing.RouteRegister) {
|
||||
// user (signed in)
|
||||
|
@ -84,6 +84,13 @@ func (hs *HTTPServer) CookieOptionsFromCfg() cookies.CookieOptions {
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) {
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) {
|
||||
c.Redirect(hs.Cfg.AppSubURL + "/")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
viewData, err := setIndexViewData(hs, c)
|
||||
if err != nil {
|
||||
c.Handle(hs.Cfg, 500, "Failed to get settings", err)
|
||||
@ -123,19 +130,7 @@ func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if redirectTo := c.GetCookie("redirect_to"); len(redirectTo) > 0 {
|
||||
if err := hs.ValidateRedirectTo(redirectTo); err != nil {
|
||||
// the user is already logged so instead of rendering the login page with error
|
||||
// it should be redirected to the home page.
|
||||
c.Logger.Debug("Ignored invalid redirect_to cookie value", "redirect_to", redirectTo)
|
||||
redirectTo = hs.Cfg.AppSubURL + "/"
|
||||
}
|
||||
cookies.DeleteCookie(c.Resp, "redirect_to", hs.CookieOptionsFromCfg)
|
||||
c.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(hs.Cfg.AppSubURL + "/")
|
||||
c.Redirect(hs.GetRedirectURL(c))
|
||||
return
|
||||
}
|
||||
|
||||
@ -286,21 +281,11 @@ func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response {
|
||||
return resp
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"message": "Logged in",
|
||||
}
|
||||
|
||||
if redirectTo := c.GetCookie("redirect_to"); len(redirectTo) > 0 {
|
||||
if err := hs.ValidateRedirectTo(redirectTo); err == nil {
|
||||
result["redirectUrl"] = redirectTo
|
||||
} else {
|
||||
c.Logger.Info("Ignored invalid redirect_to cookie value.", "url", redirectTo)
|
||||
}
|
||||
cookies.DeleteCookie(c.Resp, "redirect_to", hs.CookieOptionsFromCfg)
|
||||
}
|
||||
|
||||
metrics.MApiLoginPost.Inc()
|
||||
resp = response.JSON(http.StatusOK, result)
|
||||
resp = response.JSON(http.StatusOK, map[string]any{
|
||||
"message": "Logged in",
|
||||
"redirectUrl": hs.GetRedirectURL(c),
|
||||
})
|
||||
return resp
|
||||
}
|
||||
|
||||
@ -325,7 +310,7 @@ func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqCont
|
||||
c.UserToken = userToken
|
||||
|
||||
hs.log.Info("Successful Login", "User", user.Email)
|
||||
cookies.WriteSessionCookie(c, hs.Cfg, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetime)
|
||||
authn.WriteSessionCookie(c.Resp, hs.Cfg, userToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -353,7 +338,7 @@ func (hs *HTTPServer) Logout(c *contextmodel.ReqContext) {
|
||||
hs.log.Error("failed to revoke auth token", "error", err)
|
||||
}
|
||||
|
||||
cookies.WriteSessionCookie(c, hs.Cfg, "", -1)
|
||||
authn.DeleteSessionCookie(c.Resp, hs.Cfg)
|
||||
|
||||
if setting.SignoutRedirectUrl != "" {
|
||||
c.Redirect(setting.SignoutRedirectUrl)
|
||||
|
@ -292,16 +292,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *contextmodel.ReqContext) {
|
||||
hs.HooksService.RunLoginHook(&loginInfo, ctx)
|
||||
metrics.MApiLoginOAuth.Inc()
|
||||
|
||||
if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 {
|
||||
if err := hs.ValidateRedirectTo(redirectTo); err == nil {
|
||||
cookies.DeleteCookie(ctx.Resp, "redirect_to", hs.CookieOptionsFromCfg)
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
ctx.Logger.Debug("Ignored invalid redirect_to cookie value", "redirect_to", redirectTo)
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubUrl + "/")
|
||||
ctx.Redirect(hs.GetRedirectURL(ctx))
|
||||
}
|
||||
|
||||
// buildExternalUserInfo returns a ExternalUserInfo struct from OAuth user profile
|
||||
|
@ -73,7 +73,6 @@ type redirectCase struct {
|
||||
desc string
|
||||
url string
|
||||
status int
|
||||
err error
|
||||
appURL string
|
||||
appSubURL string
|
||||
redirectURL string
|
||||
@ -103,6 +102,7 @@ func TestLoginErrorCookieAPIEndpoint(t *testing.T) {
|
||||
License: &licensing.OSSLicensingService{},
|
||||
SocialService: &mockSocialService{},
|
||||
SecretsService: secretsService,
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
}
|
||||
|
||||
sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
@ -150,6 +150,8 @@ func TestLoginViewRedirect(t *testing.T) {
|
||||
SettingsProvider: &setting.OSSImpl{Cfg: cfg},
|
||||
License: &licensing.OSSLicensingService{},
|
||||
SocialService: &mockSocialService{},
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
hs.Cfg.CookieSecure = true
|
||||
|
||||
@ -304,12 +306,6 @@ func TestLoginViewRedirect(t *testing.T) {
|
||||
}
|
||||
assert.True(t, redirectToCookieFound)
|
||||
}
|
||||
|
||||
responseString, err := getBody(sc.resp)
|
||||
require.NoError(t, err)
|
||||
if c.err != nil {
|
||||
assert.True(t, strings.Contains(responseString, c.err.Error()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -344,83 +340,85 @@ func TestLoginPostRedirect(t *testing.T) {
|
||||
|
||||
redirectCases := []redirectCase{
|
||||
{
|
||||
desc: "grafana relative url without subpath",
|
||||
url: "/profile",
|
||||
appURL: "https://localhost:3000/",
|
||||
desc: "grafana relative url without subpath",
|
||||
url: "/profile",
|
||||
redirectURL: "/profile",
|
||||
appURL: "https://localhost:3000/",
|
||||
},
|
||||
{
|
||||
desc: "grafana relative url with subpath with leading slash",
|
||||
url: "/grafana/profile",
|
||||
appURL: "https://localhost:3000/",
|
||||
appSubURL: "/grafana",
|
||||
desc: "grafana relative url with subpath with leading slash",
|
||||
url: "/grafana/profile",
|
||||
redirectURL: "/grafana/profile",
|
||||
appURL: "https://localhost:3000/",
|
||||
appSubURL: "/grafana",
|
||||
},
|
||||
{
|
||||
desc: "grafana invalid relative url starting with subpath",
|
||||
url: "/grafanablah",
|
||||
appURL: "https://localhost:3000/",
|
||||
appSubURL: "/grafana",
|
||||
err: login.ErrInvalidRedirectTo,
|
||||
desc: "grafana invalid relative url starting with subpath",
|
||||
url: "/grafanablah",
|
||||
redirectURL: "/grafana/",
|
||||
appURL: "https://localhost:3000/",
|
||||
appSubURL: "/grafana",
|
||||
},
|
||||
{
|
||||
desc: "relative url with missing subpath",
|
||||
url: "/profile",
|
||||
appURL: "https://localhost:3000/",
|
||||
appSubURL: "/grafana",
|
||||
err: login.ErrInvalidRedirectTo,
|
||||
desc: "relative url with missing subpath",
|
||||
url: "/profile",
|
||||
redirectURL: "/grafana/",
|
||||
appURL: "https://localhost:3000/",
|
||||
appSubURL: "/grafana",
|
||||
},
|
||||
{
|
||||
desc: "grafana absolute url",
|
||||
url: "http://localhost:3000/profile",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrAbsoluteRedirectTo,
|
||||
desc: "grafana absolute url",
|
||||
url: "http://localhost:3000/profile",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "non grafana absolute url",
|
||||
url: "http://example.com",
|
||||
appURL: "https://localhost:3000/",
|
||||
err: login.ErrAbsoluteRedirectTo,
|
||||
desc: "non grafana absolute url",
|
||||
url: "http://example.com",
|
||||
appURL: "https://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "invalid URL",
|
||||
url: ":foo",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrInvalidRedirectTo,
|
||||
desc: "invalid URL",
|
||||
url: ":foo",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "non-Grafana URL without scheme",
|
||||
url: "example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrForbiddenRedirectTo,
|
||||
desc: "non-Grafana URL without scheme",
|
||||
url: "example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "non-Grafana URL without scheme",
|
||||
url: "www.example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrForbiddenRedirectTo,
|
||||
desc: "non-Grafana URL without scheme",
|
||||
url: "www.example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "URL path is a host with two leading slashes",
|
||||
url: "//example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrForbiddenRedirectTo,
|
||||
desc: "URL path is a host with two leading slashes",
|
||||
url: "//example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "URL path is a host with three leading slashes",
|
||||
url: "///example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrForbiddenRedirectTo,
|
||||
desc: "URL path is a host with three leading slashes",
|
||||
url: "///example.com",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "URL path is an IP address with two leading slashes",
|
||||
url: "//0.0.0.0",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrForbiddenRedirectTo,
|
||||
desc: "URL path is an IP address with two leading slashes",
|
||||
url: "//0.0.0.0",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
{
|
||||
desc: "URL path is an IP address with three leading slashes",
|
||||
url: "///0.0.0.0",
|
||||
appURL: "http://localhost:3000/",
|
||||
err: login.ErrForbiddenRedirectTo,
|
||||
desc: "URL path is an IP address with three leading slashes",
|
||||
url: "///0.0.0.0",
|
||||
appURL: "http://localhost:3000/",
|
||||
redirectURL: "/",
|
||||
},
|
||||
}
|
||||
|
||||
@ -449,11 +447,8 @@ func TestLoginPostRedirect(t *testing.T) {
|
||||
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
redirectURL := respJSON.Get("redirectUrl").MustString()
|
||||
if c.err != nil {
|
||||
assert.Equal(t, "", redirectURL)
|
||||
} else {
|
||||
assert.Equal(t, c.url, redirectURL)
|
||||
}
|
||||
assert.Equal(t, c.redirectURL, redirectURL)
|
||||
|
||||
// assert redirect_to cookie is deleted
|
||||
setCookie, ok := sc.resp.Header()["Set-Cookie"]
|
||||
assert.True(t, ok, "Set-Cookie exists")
|
||||
@ -491,6 +486,7 @@ func TestLoginOAuthRedirect(t *testing.T) {
|
||||
SettingsProvider: &setting.OSSImpl{Cfg: cfg},
|
||||
License: &licensing.OSSLicensingService{},
|
||||
SocialService: mock,
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
}
|
||||
|
||||
sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
@ -514,9 +510,10 @@ func TestLoginInternal(t *testing.T) {
|
||||
fakeViewIndex(t)
|
||||
sc := setupScenarioContext(t, "/login")
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
License: &licensing.OSSLicensingService{},
|
||||
log: log.New("test"),
|
||||
Cfg: setting.NewCfg(),
|
||||
License: &licensing.OSSLicensingService{},
|
||||
log: log.New("test"),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
}
|
||||
|
||||
sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
@ -569,6 +566,7 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte
|
||||
AuthTokenService: authtest.NewFakeUserAuthTokenService(),
|
||||
log: log.New("hello"),
|
||||
SocialService: &mockSocialService{},
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
}
|
||||
|
||||
sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
|
@ -10,7 +10,9 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/infra/network"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@ -52,6 +54,71 @@ func (hs *HTTPServer) RevokeUserAuthToken(c *contextmodel.ReqContext) response.R
|
||||
return hs.revokeUserAuthTokenInternal(c, c.UserID, cmd)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) RotateUserAuthTokenRedirect(c *contextmodel.ReqContext) response.Response {
|
||||
if err := hs.rotateToken(c); err != nil {
|
||||
hs.log.FromContext(c.Req.Context()).Debug("Failed to rotate token", "error", err)
|
||||
if errors.Is(err, auth.ErrInvalidSessionToken) {
|
||||
authn.DeleteSessionCookie(c.Resp, hs.Cfg)
|
||||
}
|
||||
return response.Redirect(hs.Cfg.AppSubURL + "/login")
|
||||
}
|
||||
|
||||
return response.Redirect(hs.GetRedirectURL(c))
|
||||
}
|
||||
|
||||
// swagger:route POST /user/auth-tokens/rotate
|
||||
//
|
||||
// # Rotate the auth token of the caller
|
||||
//
|
||||
// Rotate the token of caller, if successful send a new session cookie.
|
||||
//
|
||||
// Responses:
|
||||
// 200: okResponse
|
||||
// 401: unauthorisedError
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) RotateUserAuthToken(c *contextmodel.ReqContext) response.Response {
|
||||
if err := hs.rotateToken(c); err != nil {
|
||||
hs.log.FromContext(c.Req.Context()).Debug("Failed to rotate token", "error", err)
|
||||
if errors.Is(err, auth.ErrInvalidSessionToken) {
|
||||
authn.DeleteSessionCookie(c.Resp, hs.Cfg)
|
||||
return response.ErrOrFallback(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), err)
|
||||
}
|
||||
|
||||
if errors.Is(err, auth.ErrUserTokenNotFound) {
|
||||
return response.ErrOrFallback(http.StatusNotFound, http.StatusText(http.StatusFound), err)
|
||||
}
|
||||
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) rotateToken(c *contextmodel.ReqContext) error {
|
||||
token := c.GetCookie(hs.Cfg.LoginCookieName)
|
||||
ip, err := network.GetIPFromAddress(c.RemoteAddr())
|
||||
if err != nil {
|
||||
hs.log.Debug("Failed to get IP from client address", "addr", c.RemoteAddr())
|
||||
}
|
||||
|
||||
res, err := hs.AuthTokenService.RotateToken(c.Req.Context(), auth.RotateCommand{
|
||||
UnHashedToken: token,
|
||||
IP: ip,
|
||||
UserAgent: c.Req.UserAgent(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.UnhashedToken != token {
|
||||
authn.WriteSessionCookie(c.Resp, hs.Cfg, res)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, userID int64) response.Response {
|
||||
userQuery := user.GetUserByIDQuery{ID: userID}
|
||||
|
||||
@ -167,7 +234,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *contextmodel.ReqContext, us
|
||||
return response.Error(400, "Cannot revoke active user auth token", nil)
|
||||
}
|
||||
|
||||
err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, false)
|
||||
err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserTokenNotFound) {
|
||||
return response.Error(404, "User auth token not found", err)
|
||||
|
@ -3,19 +3,24 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/auth/authtest"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestUserTokenAPIEndpoint(t *testing.T) {
|
||||
@ -146,6 +151,97 @@ func TestUserTokenAPIEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPServer_RotateUserAuthToken(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
cookie *http.Cookie
|
||||
rotatedToken *auth.UserToken
|
||||
rotatedErr error
|
||||
expectedStatus int
|
||||
expectNewSession bool
|
||||
expectSessionDeleted bool
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "Should return 401 and delete cookie if the token is invalid",
|
||||
cookie: &http.Cookie{Name: "grafana_session", Value: "123", Path: "/"},
|
||||
rotatedErr: auth.ErrInvalidSessionToken,
|
||||
expectSessionDeleted: true,
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
desc: "Should return 404 and when token s not found",
|
||||
cookie: &http.Cookie{Name: "grafana_session", Value: "123", Path: "/"},
|
||||
rotatedErr: auth.ErrUserTokenNotFound,
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
desc: "Should return 200 and but not set new cookie if token was not rotated",
|
||||
cookie: &http.Cookie{Name: "grafana_session", Value: "123", Path: "/"},
|
||||
rotatedToken: &auth.UserToken{UnhashedToken: "123"},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
desc: "Should return 200 and set new session and expiry cookies",
|
||||
cookie: &http.Cookie{Name: "grafana_session", Value: "123", Path: "/"},
|
||||
rotatedToken: &auth.UserToken{UnhashedToken: "new"},
|
||||
expectNewSession: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.LoginCookieName = "grafana_session"
|
||||
cfg.LoginMaxLifetime = 10 * time.Hour
|
||||
hs.Cfg = cfg
|
||||
hs.log = log.New()
|
||||
hs.Cfg.LoginCookieName = "grafana_session"
|
||||
hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation)
|
||||
hs.AuthTokenService = &authtest.FakeUserAuthTokenService{
|
||||
RotateTokenProvider: func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
|
||||
return tt.rotatedToken, tt.rotatedErr
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
req := server.NewPostRequest("/api/user/auth-tokens/rotate", nil)
|
||||
if tt.cookie != nil {
|
||||
req.AddCookie(tt.cookie)
|
||||
}
|
||||
|
||||
res, err := server.Send(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedStatus, res.StatusCode)
|
||||
|
||||
if tt.expectedStatus != http.StatusOK {
|
||||
if tt.expectSessionDeleted {
|
||||
cookies := res.Header.Values("Set-Cookie")
|
||||
require.Len(t, cookies, 2)
|
||||
assert.Equal(t, "grafana_session=; Path=/; Max-Age=0; HttpOnly", cookies[0])
|
||||
assert.Equal(t, "grafana_session_expiry=; Path=/; Max-Age=0", cookies[1])
|
||||
} else {
|
||||
assert.Empty(t, res.Header.Get("Set-Cookie"))
|
||||
}
|
||||
} else {
|
||||
if tt.expectNewSession {
|
||||
cookies := res.Header.Values("Set-Cookie")
|
||||
require.Len(t, cookies, 2)
|
||||
assert.Equal(t, "grafana_session=new; Path=/; Max-Age=36000; HttpOnly", cookies[0])
|
||||
assert.Equal(t, "grafana_session_expiry=-5; Path=/; Max-Age=36000", cookies[1])
|
||||
} else {
|
||||
assert.Empty(t, res.Header.Get("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd auth.RevokeAuthTokenCmd,
|
||||
userId int64, fn scenarioFunc, userService user.Service) {
|
||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||
|
@ -3,8 +3,24 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/mail"
|
||||
|
||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
)
|
||||
|
||||
func (hs *HTTPServer) GetRedirectURL(c *contextmodel.ReqContext) string {
|
||||
redirectURL := hs.Cfg.AppSubURL + "/"
|
||||
if redirectTo := c.GetCookie("redirect_to"); len(redirectTo) > 0 {
|
||||
if err := hs.ValidateRedirectTo(redirectTo); err == nil {
|
||||
redirectURL = redirectTo
|
||||
} else {
|
||||
hs.log.FromContext(c.Req.Context()).Debug("Ignored invalid redirect_to cookie value", "redirect_to", redirectTo)
|
||||
}
|
||||
cookies.DeleteCookie(c.Resp, "redirect_to", hs.CookieOptionsFromCfg)
|
||||
}
|
||||
return redirectURL
|
||||
}
|
||||
|
||||
func jsonMap(data []byte) (map[string]string, error) {
|
||||
jsonMap := make(map[string]string)
|
||||
err := json.Unmarshal(data, &jsonMap)
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@ -43,6 +44,12 @@ func notAuthorized(c *contextmodel.ReqContext) {
|
||||
}
|
||||
|
||||
writeRedirectCookie(c)
|
||||
|
||||
if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) {
|
||||
c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(setting.AppSubUrl + "/login")
|
||||
}
|
||||
|
||||
|
@ -2,14 +2,12 @@ package cookies
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type CookieOptions struct {
|
||||
NotHttpOnly bool
|
||||
Path string
|
||||
Secure bool
|
||||
SameSiteDisabled bool
|
||||
@ -45,7 +43,7 @@ func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, g
|
||||
Name: name,
|
||||
MaxAge: maxAge,
|
||||
Value: value,
|
||||
HttpOnly: true,
|
||||
HttpOnly: !options.NotHttpOnly,
|
||||
Path: options.Path,
|
||||
Secure: options.Secure,
|
||||
}
|
||||
@ -54,18 +52,3 @@ func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, g
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
func WriteSessionCookie(ctx *contextmodel.ReqContext, cfg *setting.Cfg, value string, maxLifetime time.Duration) {
|
||||
if cfg.Env == setting.Dev {
|
||||
ctx.Logger.Info("New token", "unhashed token", value)
|
||||
}
|
||||
|
||||
var maxAge int
|
||||
if maxLifetime <= 0 {
|
||||
maxAge = -1
|
||||
} else {
|
||||
maxAge = int(maxLifetime.Seconds())
|
||||
}
|
||||
|
||||
WriteCookie(ctx.Resp, cfg.LoginCookieName, url.QueryEscape(value), maxAge, nil)
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package usertoken
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidSessionToken = errors.New("invalid session token")
|
||||
@ -35,3 +36,21 @@ type UserToken struct {
|
||||
RevokedAt int64
|
||||
UnhashedToken string
|
||||
}
|
||||
|
||||
const UrgentRotateTime = 1 * time.Minute
|
||||
|
||||
func (t *UserToken) NeedsRotation(rotationInterval time.Duration) bool {
|
||||
rotatedAt := time.Unix(t.RotatedAt, 0)
|
||||
if !t.AuthTokenSeen {
|
||||
return rotatedAt.Before(time.Now().Add(-UrgentRotateTime))
|
||||
}
|
||||
|
||||
return rotatedAt.Before(time.Now().Add(-rotationInterval))
|
||||
}
|
||||
|
||||
const rotationLeeway = 5 * time.Second
|
||||
|
||||
func (t *UserToken) NextRotation(rotationInterval time.Duration) time.Time {
|
||||
rotatedAt := time.Unix(t.RotatedAt, 0)
|
||||
return rotatedAt.Add(rotationInterval - rotationLeeway)
|
||||
}
|
||||
|
25
pkg/models/usertoken/user_token_test.go
Normal file
25
pkg/models/usertoken/user_token_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package usertoken
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserToken_NeedsRotation(t *testing.T) {
|
||||
t.Run("should return true", func(t *testing.T) {
|
||||
token := &UserToken{AuthTokenSeen: true, RotatedAt: time.Now().Add(-11 * time.Minute).Unix()}
|
||||
assert.True(t, token.NeedsRotation(10*time.Minute))
|
||||
})
|
||||
|
||||
t.Run("should return true when token is not seen", func(t *testing.T) {
|
||||
token := &UserToken{AuthTokenSeen: false, RotatedAt: time.Now().Add(-2 * time.Minute).Unix()}
|
||||
assert.True(t, token.NeedsRotation(10*time.Minute))
|
||||
})
|
||||
|
||||
t.Run("should return false", func(t *testing.T) {
|
||||
token := &UserToken{AuthTokenSeen: true, RotatedAt: time.Now().Add(-9 * time.Minute).Unix()}
|
||||
assert.False(t, token.NeedsRotation(10*time.Minute))
|
||||
})
|
||||
}
|
@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@ -39,12 +40,18 @@ func Middleware(ac AccessControl) func(web.Handler, Evaluator) web.Handler {
|
||||
|
||||
if !c.IsSignedIn && forceLogin {
|
||||
unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var revokedErr *usertoken.TokenRevokedError
|
||||
if errors.As(c.LookupTokenErr, &revokedErr) {
|
||||
unauthorized(c, revokedErr)
|
||||
if c.LookupTokenErr != nil {
|
||||
var revokedErr *usertoken.TokenRevokedError
|
||||
if errors.As(c.LookupTokenErr, &revokedErr) {
|
||||
tokenRevoked(c, revokedErr)
|
||||
return
|
||||
}
|
||||
|
||||
unauthorized(c, c.LookupTokenErr)
|
||||
return
|
||||
}
|
||||
|
||||
@ -108,20 +115,28 @@ func deny(c *contextmodel.ReqContext, evaluator Evaluator, err error) {
|
||||
|
||||
func unauthorized(c *contextmodel.ReqContext, err error) {
|
||||
if c.IsApiRequest() {
|
||||
response := map[string]interface{}{
|
||||
"message": "Unauthorized",
|
||||
}
|
||||
c.WriteErrOrFallback(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), c.LookupTokenErr)
|
||||
return
|
||||
}
|
||||
|
||||
var revokedErr *usertoken.TokenRevokedError
|
||||
if errors.As(err, &revokedErr) {
|
||||
response["message"] = "Token revoked"
|
||||
response["error"] = map[string]interface{}{
|
||||
writeRedirectCookie(c)
|
||||
if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) {
|
||||
c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(setting.AppSubUrl + "/login")
|
||||
}
|
||||
|
||||
func tokenRevoked(c *contextmodel.ReqContext, err *usertoken.TokenRevokedError) {
|
||||
if c.IsApiRequest() {
|
||||
c.JSON(http.StatusUnauthorized, map[string]interface{}{
|
||||
"message": "Token revoked",
|
||||
"error": map[string]interface{}{
|
||||
"id": "ERR_TOKEN_REVOKED",
|
||||
"maxConcurrentSessions": revokedErr.MaxConcurrentSessions,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusUnauthorized, response)
|
||||
"maxConcurrentSessions": err.MaxConcurrentSessions,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -58,10 +58,19 @@ type RevokeAuthTokenCmd struct {
|
||||
AuthTokenId int64 `json:"authTokenId"`
|
||||
}
|
||||
|
||||
type RotateCommand struct {
|
||||
// token is the un-hashed token
|
||||
UnHashedToken string
|
||||
IP net.IP
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// UserTokenService are used for generating and validating user tokens
|
||||
type UserTokenService interface {
|
||||
CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*UserToken, error)
|
||||
LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error)
|
||||
// RotateToken will always rotate a valid token
|
||||
RotateToken(ctx context.Context, cmd RotateCommand) (*UserToken, error)
|
||||
TryRotateToken(ctx context.Context, token *UserToken, clientIP net.IP, userAgent string) (bool, *UserToken, error)
|
||||
RevokeToken(ctx context.Context, token *UserToken, soft bool) error
|
||||
RevokeAllUserTokens(ctx context.Context, userId int64) error
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
@ -14,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@ -21,9 +23,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
const urgentRotateTime = 1 * time.Minute
|
||||
|
||||
var getTime = time.Now
|
||||
var (
|
||||
getTime = time.Now
|
||||
errTokenNotRotated = errors.New("token was not rotated")
|
||||
)
|
||||
|
||||
func ProvideUserAuthTokenService(sqlStore db.DB,
|
||||
serverLockService *serverlock.ServerLockService,
|
||||
@ -62,13 +65,11 @@ type UserAuthTokenService struct {
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
|
||||
token, err := util.RandomHex(16)
|
||||
token, hashedToken, err := generateAndHashToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hashedToken := hashToken(token)
|
||||
|
||||
now := getTime().Unix()
|
||||
clientIPStr := clientIP.String()
|
||||
if len(clientIP) == 0 {
|
||||
@ -150,17 +151,16 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
|
||||
// Current incoming token is the previous auth token in the DB and the auth_token_seen is true
|
||||
if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen {
|
||||
modelCopy := model
|
||||
modelCopy.AuthTokenSeen = false
|
||||
expireBefore := getTime().Add(-urgentRotateTime).Unix()
|
||||
model.AuthTokenSeen = false
|
||||
model.RotatedAt = getTime().Add(-usertoken.UrgentRotateTime).Unix()
|
||||
|
||||
var affectedRows int64
|
||||
err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
|
||||
affectedRows, err = dbSession.Where("id = ? AND prev_auth_token = ? AND rotated_at < ?",
|
||||
modelCopy.Id,
|
||||
modelCopy.PrevAuthToken,
|
||||
expireBefore).
|
||||
AllCols().Update(&modelCopy)
|
||||
model.Id,
|
||||
model.PrevAuthToken,
|
||||
model.RotatedAt).
|
||||
AllCols().Update(&model)
|
||||
|
||||
return err
|
||||
})
|
||||
@ -178,16 +178,15 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
|
||||
// Current incoming token is not seen and it is the latest valid auth token in the db
|
||||
if !model.AuthTokenSeen && model.AuthToken == hashedToken {
|
||||
modelCopy := model
|
||||
modelCopy.AuthTokenSeen = true
|
||||
modelCopy.SeenAt = getTime().Unix()
|
||||
model.AuthTokenSeen = true
|
||||
model.SeenAt = getTime().Unix()
|
||||
|
||||
var affectedRows int64
|
||||
err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
|
||||
affectedRows, err = dbSession.Where("id = ? AND auth_token = ?",
|
||||
modelCopy.Id,
|
||||
modelCopy.AuthToken).
|
||||
AllCols().Update(&modelCopy)
|
||||
model.Id,
|
||||
model.AuthToken).
|
||||
AllCols().Update(&model)
|
||||
|
||||
return err
|
||||
})
|
||||
@ -196,10 +195,6 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if affectedRows == 1 {
|
||||
model = modelCopy
|
||||
}
|
||||
|
||||
if affectedRows == 0 {
|
||||
ctxLogger.Debug("seen wrong token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
|
||||
} else {
|
||||
@ -215,6 +210,90 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
return &userToken, err
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
|
||||
if cmd.UnHashedToken == "" {
|
||||
return nil, auth.ErrInvalidSessionToken
|
||||
}
|
||||
|
||||
res, err, _ := s.singleflight.Do(cmd.UnHashedToken, func() (interface{}, error) {
|
||||
token, err := s.LookupToken(ctx, cmd.UnHashedToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newToken, err := s.rotateToken(ctx, token, cmd.IP, cmd.UserAgent)
|
||||
|
||||
if errors.Is(err, errTokenNotRotated) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newToken, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.(*auth.UserToken), nil
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
|
||||
var clientIPStr string
|
||||
if clientIP != nil {
|
||||
clientIPStr = clientIP.String()
|
||||
}
|
||||
|
||||
newToken, hashedToken, err := generateAndHashToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sql := `
|
||||
UPDATE user_auth_token
|
||||
SET
|
||||
seen_at = 0,
|
||||
user_agent = ?,
|
||||
client_ip = ?,
|
||||
prev_auth_token = auth_token,
|
||||
auth_token = ?,
|
||||
auth_token_seen = ?,
|
||||
rotated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
now := getTime()
|
||||
var affected int64
|
||||
err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
|
||||
res, err := dbSession.Exec(sql, userAgent, clientIPStr, hashedToken, s.sqlStore.GetDialect().BooleanStr(false), now.Unix(), token.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
affected, err = res.RowsAffected()
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if affected < 1 {
|
||||
return nil, errTokenNotRotated
|
||||
}
|
||||
|
||||
token.PrevAuthToken = token.AuthToken
|
||||
token.AuthToken = hashedToken
|
||||
token.UnhashedToken = newToken
|
||||
token.AuthTokenSeen = false
|
||||
token.RotatedAt = now.Unix()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.UserToken,
|
||||
clientIP net.IP, userAgent string) (bool, *auth.UserToken, error) {
|
||||
if token == nil {
|
||||
@ -239,7 +318,7 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.U
|
||||
if model.AuthTokenSeen {
|
||||
needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute))
|
||||
} else {
|
||||
needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime))
|
||||
needsRotation = rotatedAt.Before(now.Add(-usertoken.UrgentRotateTime))
|
||||
}
|
||||
|
||||
if !needsRotation {
|
||||
@ -504,11 +583,29 @@ func (s *UserAuthTokenService) rotatedAfterParam() int64 {
|
||||
return getTime().Add(-s.cfg.LoginMaxInactiveLifetime).Unix()
|
||||
}
|
||||
|
||||
func createToken() (string, error) {
|
||||
token, err := util.RandomHex(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
|
||||
return hex.EncodeToString(hashBytes[:])
|
||||
}
|
||||
|
||||
func generateAndHashToken() (string, string, error) {
|
||||
token, err := createToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return token, hashToken(token), nil
|
||||
}
|
||||
|
||||
func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
|
||||
limits := "a.Map{}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
@ -311,7 +312,7 @@ func TestUserAuthToken(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, lookedUpUserToken)
|
||||
require.Equal(t, model.Id, lookedUpUserToken.Id)
|
||||
require.True(t, lookedUpUserToken.AuthTokenSeen)
|
||||
require.False(t, lookedUpUserToken.AuthTokenSeen)
|
||||
|
||||
getTime = func() time.Time {
|
||||
return now.Add(time.Hour + (2 * time.Minute))
|
||||
@ -320,7 +321,7 @@ func TestUserAuthToken(t *testing.T) {
|
||||
lookedUpUserToken, err = ctx.tokenService.LookupToken(context.Background(), unhashedPrev)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, lookedUpUserToken)
|
||||
require.True(t, lookedUpUserToken.AuthTokenSeen)
|
||||
require.False(t, lookedUpUserToken.AuthTokenSeen)
|
||||
|
||||
lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
|
||||
require.Nil(t, err)
|
||||
@ -390,7 +391,7 @@ func TestUserAuthToken(t *testing.T) {
|
||||
require.True(t, lookedUpModel.AuthTokenSeen)
|
||||
})
|
||||
|
||||
t.Run("Rotate token", func(t *testing.T) {
|
||||
t.Run("TryRotateToken", func(t *testing.T) {
|
||||
t.Run("Should rotate current token and previous token when auth token seen", func(t *testing.T) {
|
||||
getTime = func() time.Time { return now }
|
||||
userToken, err := ctx.tokenService.CreateToken(context.Background(), user,
|
||||
@ -471,6 +472,59 @@ func TestUserAuthToken(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RotateToken", func(t *testing.T) {
|
||||
var prev string
|
||||
token, err := ctx.tokenService.CreateToken(context.Background(), user, nil, "")
|
||||
require.NoError(t, err)
|
||||
t.Run("should rotate token when called with current auth token", func(t *testing.T) {
|
||||
prev = token.UnhashedToken
|
||||
token, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: token.UnhashedToken})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, token.UnhashedToken != prev)
|
||||
assert.True(t, token.PrevAuthToken == hashToken(prev))
|
||||
})
|
||||
|
||||
t.Run("should rotate token when called with previous", func(t *testing.T) {
|
||||
newPrev := token.UnhashedToken
|
||||
token, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: prev})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, token.PrevAuthToken == hashToken(newPrev))
|
||||
})
|
||||
|
||||
t.Run("should not rotate token when called with old previous", func(t *testing.T) {
|
||||
_, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: prev})
|
||||
require.ErrorIs(t, err, auth.ErrUserTokenNotFound)
|
||||
})
|
||||
|
||||
t.Run("should return error when token is revoked", func(t *testing.T) {
|
||||
revokedToken, err := ctx.tokenService.CreateToken(context.Background(), user, nil, "")
|
||||
require.NoError(t, err)
|
||||
// mark token as revoked
|
||||
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
_, err := sess.Exec("UPDATE user_auth_token SET revoked_at = 1 WHERE id = ?", revokedToken.Id)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: revokedToken.UnhashedToken})
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidSessionToken)
|
||||
})
|
||||
|
||||
t.Run("should return error when token has expired", func(t *testing.T) {
|
||||
expiredToken, err := ctx.tokenService.CreateToken(context.Background(), user, nil, "")
|
||||
require.NoError(t, err)
|
||||
// mark token as expired
|
||||
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
_, err := sess.Exec("UPDATE user_auth_token SET created_at = 1 WHERE id = ?", expiredToken.Id)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: expiredToken.UnhashedToken})
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidSessionToken)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) {
|
||||
ut := auth.UserToken{
|
||||
Id: 1,
|
||||
|
@ -68,6 +68,5 @@ func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error {
|
||||
ut.UpdatedAt = uat.UpdatedAt
|
||||
ut.RevokedAt = uat.RevokedAt
|
||||
ut.UnhashedToken = uat.UnhashedToken
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
|
||||
type FakeUserAuthTokenService struct {
|
||||
CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error)
|
||||
RotateTokenProvider func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error)
|
||||
TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error)
|
||||
LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error)
|
||||
RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error
|
||||
@ -74,6 +75,10 @@ func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.U
|
||||
return s.CreateTokenProvider(context.Background(), user, clientIP, userAgent)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
|
||||
return s.RotateTokenProvider(ctx, cmd)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
|
||||
return s.LookupTokenProvider(context.Background(), unhashedToken)
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@ -219,7 +219,7 @@ type Identity struct {
|
||||
// OAuthToken is the OAuth token used to authenticate the entity.
|
||||
OAuthToken *oauth2.Token
|
||||
// SessionToken is the session token used to authenticate the entity.
|
||||
SessionToken *auth.UserToken
|
||||
SessionToken *usertoken.UserToken
|
||||
// ClientParams are hints for the auth service on how to handle the identity.
|
||||
// Set by the authenticating client.
|
||||
ClientParams ClientParams
|
||||
@ -363,7 +363,7 @@ func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, ident
|
||||
redirectURL = redirectTo
|
||||
}
|
||||
|
||||
WriteSessionCookie(w, cfg, identity)
|
||||
WriteSessionCookie(w, cfg, identity.SessionToken)
|
||||
return redirectURL
|
||||
}
|
||||
|
||||
@ -377,11 +377,28 @@ func getRedirectURL(r *http.Request) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func WriteSessionCookie(w http.ResponseWriter, cfg *setting.Cfg, identity *Identity) {
|
||||
const sessionExpiryCookie = "grafana_session_expiry"
|
||||
|
||||
func WriteSessionCookie(w http.ResponseWriter, cfg *setting.Cfg, token *usertoken.UserToken) {
|
||||
maxAge := int(cfg.LoginMaxLifetime.Seconds())
|
||||
if cfg.LoginMaxLifetime <= 0 {
|
||||
maxAge = -1
|
||||
}
|
||||
|
||||
cookies.WriteCookie(w, cfg.LoginCookieName, url.QueryEscape(identity.SessionToken.UnhashedToken), maxAge, nil)
|
||||
cookies.WriteCookie(w, cfg.LoginCookieName, url.QueryEscape(token.UnhashedToken), maxAge, nil)
|
||||
expiry := token.NextRotation(time.Duration(cfg.TokenRotationIntervalMinutes) * time.Minute)
|
||||
cookies.WriteCookie(w, sessionExpiryCookie, url.QueryEscape(strconv.FormatInt(expiry.Unix(), 10)), maxAge, func() cookies.CookieOptions {
|
||||
opts := cookies.NewCookieOptions()
|
||||
opts.NotHttpOnly = true
|
||||
return opts
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteSessionCookie(w http.ResponseWriter, cfg *setting.Cfg) {
|
||||
cookies.DeleteCookie(w, cfg.LoginCookieName, nil)
|
||||
cookies.DeleteCookie(w, sessionExpiryCookie, func() cookies.CookieOptions {
|
||||
opts := cookies.NewCookieOptions()
|
||||
opts.NotHttpOnly = true
|
||||
return opts
|
||||
})
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package authnimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -81,7 +82,7 @@ func ProvideService(
|
||||
s.RegisterClient(clients.ProvideAPIKey(apikeyService, userService))
|
||||
|
||||
if cfg.LoginCookieName != "" {
|
||||
s.RegisterClient(clients.ProvideSession(sessionService, userService, cfg))
|
||||
s.RegisterClient(clients.ProvideSession(cfg, sessionService, features))
|
||||
}
|
||||
|
||||
if s.cfg.AnonymousEnabled {
|
||||
@ -187,6 +188,12 @@ func (s *Service) Authenticate(ctx context.Context, r *authn.Request) (*authn.Id
|
||||
if item.v.Test(ctx, r) {
|
||||
identity, err := s.authenticate(ctx, item.v, r)
|
||||
if err != nil {
|
||||
// Note: special case for token rotation
|
||||
// We don't want to fallthrough in this case
|
||||
if errors.Is(err, authn.ErrTokenNeedsRotation) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authErr = multierror.Append(authErr, err)
|
||||
// try next
|
||||
continue
|
||||
|
@ -4,12 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/network"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@ -17,19 +18,19 @@ import (
|
||||
var _ authn.HookClient = new(Session)
|
||||
var _ authn.ContextAwareClient = new(Session)
|
||||
|
||||
func ProvideSession(sessionService auth.UserTokenService, userService user.Service, cfg *setting.Cfg) *Session {
|
||||
func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService, features *featuremgmt.FeatureManager) *Session {
|
||||
return &Session{
|
||||
cfg: cfg,
|
||||
features: features,
|
||||
sessionService: sessionService,
|
||||
userService: userService,
|
||||
log: log.New(authn.ClientSession),
|
||||
}
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
cfg *setting.Cfg
|
||||
features *featuremgmt.FeatureManager
|
||||
sessionService auth.UserTokenService
|
||||
userService user.Service
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
@ -54,18 +55,20 @@ func (s *Session) Authenticate(ctx context.Context, r *authn.Request) (*authn.Id
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signedInUser, err := s.userService.GetSignedInUserWithCacheCtx(
|
||||
ctx, &user.GetSignedInUserQuery{UserID: token.UserId, OrgID: r.OrgID},
|
||||
)
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("Failed to get user with id", "userId", token.UserId, "error", err)
|
||||
return nil, err
|
||||
if s.features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
if token.NeedsRotation(time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute) {
|
||||
return nil, authn.ErrTokenNeedsRotation.Errorf("token needs to be rotated")
|
||||
}
|
||||
}
|
||||
|
||||
identity := authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, signedInUser.UserID), signedInUser, authn.ClientParams{SyncPermissions: true})
|
||||
identity.SessionToken = token
|
||||
|
||||
return identity, nil
|
||||
return &authn.Identity{
|
||||
ID: authn.NamespacedID(authn.NamespaceUser, token.UserId),
|
||||
SessionToken: token,
|
||||
ClientParams: authn.ClientParams{
|
||||
FetchSyncedUser: true,
|
||||
SyncPermissions: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Session) Test(ctx context.Context, r *authn.Request) bool {
|
||||
@ -85,7 +88,7 @@ func (s *Session) Priority() uint {
|
||||
}
|
||||
|
||||
func (s *Session) Hook(ctx context.Context, identity *authn.Identity, r *authn.Request) error {
|
||||
if identity.SessionToken == nil {
|
||||
if identity.SessionToken == nil || s.features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -114,7 +117,7 @@ func (s *Session) Hook(ctx context.Context, identity *authn.Identity, r *authn.R
|
||||
identity.SessionToken = newToken
|
||||
s.log.Debug("rotated session token", "user", identity.ID)
|
||||
|
||||
authn.WriteSessionCookie(w, s.cfg, identity)
|
||||
authn.WriteSessionCookie(w, s.cfg, identity.SessionToken)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -7,17 +7,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/auth/authtest"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
@ -31,7 +29,7 @@ func TestSession_Test(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.LoginCookieName = ""
|
||||
cfg.LoginMaxLifetime = 20 * time.Second
|
||||
s := ProvideSession(&authtest.FakeUserAuthTokenService{}, &usertest.FakeUserService{}, cfg)
|
||||
s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, featuremgmt.WithFeatures())
|
||||
|
||||
disabled := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq})
|
||||
assert.False(t, disabled)
|
||||
@ -55,26 +53,18 @@ func TestSession_Authenticate(t *testing.T) {
|
||||
}
|
||||
validHTTPReq.AddCookie(&http.Cookie{Name: cookieName, Value: "bob-the-high-entropy-token"})
|
||||
|
||||
sampleToken := &usertoken.UserToken{
|
||||
validToken := &usertoken.UserToken{
|
||||
Id: 1,
|
||||
UserId: 1,
|
||||
AuthToken: "hashyToken",
|
||||
PrevAuthToken: "prevHashyToken",
|
||||
AuthTokenSeen: true,
|
||||
}
|
||||
|
||||
sampleUser := &user.SignedInUser{
|
||||
UserID: 1,
|
||||
Name: "sample user",
|
||||
Login: "sample_user",
|
||||
Email: "sample_user@samples.iwz",
|
||||
OrgID: 1,
|
||||
OrgRole: roletype.RoleEditor,
|
||||
RotatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
type fields struct {
|
||||
sessionService auth.UserTokenService
|
||||
userService user.Service
|
||||
features *featuremgmt.FeatureManager
|
||||
}
|
||||
type args struct {
|
||||
r *authn.Request
|
||||
@ -87,29 +77,63 @@ func TestSession_Authenticate(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "cookie not found",
|
||||
fields: fields{sessionService: &authtest.FakeUserAuthTokenService{}, userService: &usertest.FakeUserService{}},
|
||||
name: "cookie not found",
|
||||
fields: fields{
|
||||
sessionService: &authtest.FakeUserAuthTokenService{},
|
||||
features: featuremgmt.WithFeatures(),
|
||||
},
|
||||
args: args{r: &authn.Request{HTTPRequest: &http.Request{}}},
|
||||
wantID: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
|
||||
return sampleToken, nil
|
||||
}}, userService: &usertest.FakeUserService{ExpectedSignedInUser: sampleUser}},
|
||||
fields: fields{
|
||||
sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
|
||||
return validToken, nil
|
||||
}},
|
||||
features: featuremgmt.WithFeatures(),
|
||||
},
|
||||
args: args{r: &authn.Request{HTTPRequest: validHTTPReq}},
|
||||
wantID: &authn.Identity{
|
||||
SessionToken: sampleToken,
|
||||
ID: "user:1",
|
||||
Name: "sample user",
|
||||
Login: "sample_user",
|
||||
Email: "sample_user@samples.iwz",
|
||||
OrgID: 1,
|
||||
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleEditor},
|
||||
IsGrafanaAdmin: boolPtr(false),
|
||||
ID: "user:1",
|
||||
SessionToken: validToken,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncPermissions: true,
|
||||
FetchSyncedUser: true,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should return error for token that needs rotation if ClientTokenRotation is enabled",
|
||||
fields: fields{
|
||||
sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
|
||||
return &auth.UserToken{
|
||||
AuthTokenSeen: true,
|
||||
RotatedAt: time.Now().Add(-11 * time.Minute).Unix(),
|
||||
}, nil
|
||||
}},
|
||||
features: featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation),
|
||||
},
|
||||
args: args{r: &authn.Request{HTTPRequest: validHTTPReq}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return identity for token that don't need rotation if ClientTokenRotation is enabled",
|
||||
fields: fields{
|
||||
sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
|
||||
return validToken, nil
|
||||
}},
|
||||
features: featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation),
|
||||
},
|
||||
args: args{r: &authn.Request{HTTPRequest: validHTTPReq}},
|
||||
wantID: &authn.Identity{
|
||||
ID: "user:1",
|
||||
SessionToken: validToken,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncPermissions: true,
|
||||
FetchSyncedUser: true,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@ -119,8 +143,9 @@ func TestSession_Authenticate(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.LoginCookieName = cookieName
|
||||
cfg.TokenRotationIntervalMinutes = 10
|
||||
cfg.LoginMaxLifetime = 20 * time.Second
|
||||
s := ProvideSession(tt.fields.sessionService, tt.fields.userService, cfg)
|
||||
s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.features)
|
||||
|
||||
got, err := s.Authenticate(context.Background(), tt.args.r)
|
||||
require.True(t, (err != nil) == tt.wantErr, err)
|
||||
@ -151,43 +176,54 @@ func (f *fakeResponseWriter) WriteHeader(statusCode int) {
|
||||
}
|
||||
|
||||
func TestSession_Hook(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.LoginCookieName = "grafana-session"
|
||||
cfg.LoginMaxLifetime = 20 * time.Second
|
||||
s := ProvideSession(&authtest.FakeUserAuthTokenService{
|
||||
TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error) {
|
||||
token.UnhashedToken = "new-token"
|
||||
return true, token, nil
|
||||
},
|
||||
}, &usertest.FakeUserService{}, cfg)
|
||||
t.Run("should rotate token", func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.LoginCookieName = "grafana-session"
|
||||
cfg.LoginMaxLifetime = 20 * time.Second
|
||||
s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{
|
||||
TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error) {
|
||||
token.UnhashedToken = "new-token"
|
||||
return true, token, nil
|
||||
},
|
||||
}, featuremgmt.WithFeatures())
|
||||
|
||||
sampleID := &authn.Identity{
|
||||
SessionToken: &auth.UserToken{
|
||||
Id: 1,
|
||||
UserId: 1,
|
||||
},
|
||||
}
|
||||
sampleID := &authn.Identity{
|
||||
SessionToken: &auth.UserToken{
|
||||
Id: 1,
|
||||
UserId: 1,
|
||||
},
|
||||
}
|
||||
|
||||
mockResponseWriter := &fakeResponseWriter{
|
||||
Status: 0,
|
||||
HeaderStore: map[string][]string{},
|
||||
}
|
||||
mockResponseWriter := &fakeResponseWriter{
|
||||
Status: 0,
|
||||
HeaderStore: map[string][]string{},
|
||||
}
|
||||
|
||||
resp := &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
},
|
||||
Resp: web.NewResponseWriter(http.MethodConnect, mockResponseWriter),
|
||||
}
|
||||
resp := &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
},
|
||||
Resp: web.NewResponseWriter(http.MethodConnect, mockResponseWriter),
|
||||
}
|
||||
|
||||
err := s.Hook(context.Background(), sampleID, resp)
|
||||
require.NoError(t, err)
|
||||
err := s.Hook(context.Background(), sampleID, resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp.Resp.WriteHeader(201)
|
||||
require.Equal(t, 201, mockResponseWriter.Status)
|
||||
resp.Resp.WriteHeader(201)
|
||||
require.Equal(t, 201, mockResponseWriter.Status)
|
||||
|
||||
assert.Equal(t, "new-token", sampleID.SessionToken.UnhashedToken)
|
||||
require.Len(t, mockResponseWriter.HeaderStore, 1)
|
||||
assert.Equal(t, "grafana-session=new-token; Path=/; Max-Age=20; HttpOnly",
|
||||
mockResponseWriter.HeaderStore.Get("set-cookie"), mockResponseWriter.HeaderStore)
|
||||
assert.Equal(t, "new-token", sampleID.SessionToken.UnhashedToken)
|
||||
require.Len(t, mockResponseWriter.HeaderStore, 1)
|
||||
assert.Equal(t, "grafana-session=new-token; Path=/; Max-Age=20; HttpOnly",
|
||||
mockResponseWriter.HeaderStore.Get("set-cookie"), mockResponseWriter.HeaderStore)
|
||||
})
|
||||
|
||||
t.Run("should not rotate token with feature flag", func(t *testing.T) {
|
||||
s := ProvideSession(setting.NewCfg(), nil, featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation))
|
||||
|
||||
req := &authn.Request{}
|
||||
identity := &authn.Identity{}
|
||||
err := s.Hook(context.Background(), identity, req)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package authn
|
||||
import "github.com/grafana/grafana/pkg/util/errutil"
|
||||
|
||||
var (
|
||||
ErrTokenNeedsRotation = errutil.NewBase(errutil.StatusUnauthorized, "session.token.rotate")
|
||||
ErrUnsupportedClient = errutil.NewBase(errutil.StatusBadRequest, "auth.client.unsupported")
|
||||
ErrClientNotConfigured = errutil.NewBase(errutil.StatusBadRequest, "auth.client.notConfigured")
|
||||
ErrUnsupportedIdentity = errutil.NewBase(errutil.StatusNotImplemented, "auth.identity.unsupported")
|
||||
|
@ -21,7 +21,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
loginpkg "github.com/grafana/grafana/pkg/login"
|
||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
@ -145,10 +144,11 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler {
|
||||
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
||||
identity, err := h.authnService.Authenticate(ctx, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserTokenNotFound) || errors.Is(err, auth.ErrInvalidSessionToken) {
|
||||
if errors.Is(err, auth.ErrInvalidSessionToken) {
|
||||
// Burn the cookie in case of invalid, expired or missing token
|
||||
reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext))
|
||||
}
|
||||
|
||||
// Hack: set all errors on LookupTokenErr, so we can check it in auth middlewares
|
||||
reqContext.LookupTokenErr = err
|
||||
} else {
|
||||
@ -493,6 +493,13 @@ func (h *ContextHandler) initContextWithToken(reqContext *contextmodel.ReqContex
|
||||
return false
|
||||
}
|
||||
|
||||
if h.features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
if token.NeedsRotation(time.Duration(h.Cfg.TokenRotationIntervalMinutes) * time.Minute) {
|
||||
reqContext.LookupTokenErr = authn.ErrTokenNeedsRotation.Errorf("token needs rotation")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
query := user.GetSignedInUserQuery{UserID: token.UserId, OrgID: orgID}
|
||||
queryResult, err := h.userService.GetSignedInUserWithCacheCtx(ctx, &query)
|
||||
if err != nil {
|
||||
@ -541,18 +548,25 @@ func (h *ContextHandler) initContextWithToken(reqContext *contextmodel.ReqContex
|
||||
|
||||
func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *contextmodel.ReqContext) web.BeforeFunc {
|
||||
return func(w web.ResponseWriter) {
|
||||
if h.features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
return
|
||||
}
|
||||
|
||||
if w.Written() {
|
||||
reqContext.Logger.Debug("Response written, skipping invalid cookie delete")
|
||||
return
|
||||
}
|
||||
|
||||
reqContext.Logger.Debug("Expiring invalid cookie")
|
||||
cookies.DeleteCookie(reqContext.Resp, h.Cfg.LoginCookieName, nil)
|
||||
authn.DeleteSessionCookie(reqContext.Resp, h.Cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *contextmodel.ReqContext) web.BeforeFunc {
|
||||
return func(w web.ResponseWriter) {
|
||||
if h.features.IsEnabled(featuremgmt.FlagClientTokenRotation) {
|
||||
return
|
||||
}
|
||||
// if response has already been written, skip.
|
||||
if w.Written() {
|
||||
return
|
||||
@ -587,7 +601,7 @@ func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *contextmodel.ReqCont
|
||||
|
||||
if rotated {
|
||||
reqContext.UserToken = newToken
|
||||
cookies.WriteSessionCookie(reqContext, h.Cfg, newToken.UnhashedToken, h.Cfg.LoginMaxLifetime)
|
||||
authn.WriteSessionCookie(reqContext.Resp, h.Cfg, newToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -428,5 +428,11 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
},
|
||||
{
|
||||
Name: "clientTokenRotation",
|
||||
Description: "Replaces the current in-request token rotation so that the client initiates the rotation",
|
||||
State: FeatureStateAlpha,
|
||||
Owner: grafanaAuthnzSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -63,3 +63,4 @@ traceqlSearch,alpha,@grafana/observability-traces-and-profiling,false,false,fals
|
||||
prometheusMetricEncyclopedia,alpha,@grafana/observability-metrics,false,false,false,true
|
||||
timeSeriesTable,alpha,@grafana/app-o11y,false,false,false,true
|
||||
influxdbBackendMigration,alpha,@grafana/observability-metrics,false,false,false,true
|
||||
clientTokenRotation,alpha,@grafana/grafana-authnz-team,false,false,false,false
|
||||
|
|
@ -262,4 +262,8 @@ const (
|
||||
// FlagInfluxdbBackendMigration
|
||||
// Query InfluxDB InfluxQL without the proxy
|
||||
FlagInfluxdbBackendMigration = "influxdbBackendMigration"
|
||||
|
||||
// FlagClientTokenRotation
|
||||
// Replaces the current in-request token rotation so that the client initiates the rotation
|
||||
FlagClientTokenRotation = "clientTokenRotation"
|
||||
)
|
||||
|
@ -64,7 +64,6 @@ export class BackendSrv implements BackendService {
|
||||
contextSrv: contextSrv,
|
||||
logout: () => {
|
||||
contextSrv.setLoggedOut();
|
||||
window.location.reload();
|
||||
},
|
||||
};
|
||||
|
||||
@ -261,7 +260,12 @@ export class BackendSrv implements BackendService {
|
||||
return of({});
|
||||
}
|
||||
|
||||
return from(this.loginPing()).pipe(
|
||||
let authChecker = () => this.loginPing();
|
||||
if (config.featureToggles.clientTokenRotation) {
|
||||
authChecker = () => this.rotateToken();
|
||||
}
|
||||
|
||||
return from(authChecker()).pipe(
|
||||
catchError((err) => {
|
||||
if (err.status === 401) {
|
||||
this.dependencies.logout();
|
||||
@ -445,6 +449,10 @@ export class BackendSrv implements BackendService {
|
||||
});
|
||||
}
|
||||
|
||||
rotateToken() {
|
||||
return this.request({ url: '/api/user/auth-tokens/rotate', method: 'POST' });
|
||||
}
|
||||
|
||||
loginPing() {
|
||||
return this.request({ url: '/api/login/ping', method: 'GET', retry: 1 });
|
||||
}
|
||||
|
@ -73,6 +73,8 @@ export class ContextSrv {
|
||||
hasEditPermissionInFolders: boolean;
|
||||
minRefreshInterval: string;
|
||||
|
||||
private tokenRotationJobId = 0;
|
||||
|
||||
constructor() {
|
||||
if (!config.bootData) {
|
||||
config.bootData = { user: {}, settings: {}, navTree: [] } as any;
|
||||
@ -84,6 +86,10 @@ export class ContextSrv {
|
||||
this.isEditor = this.hasRole('Editor') || this.hasRole('Admin');
|
||||
this.hasEditPermissionInFolders = this.user.hasEditPermissionInFolders;
|
||||
this.minRefreshInterval = config.minRefreshInterval;
|
||||
|
||||
if (this.isSignedIn) {
|
||||
this.scheduleTokenRotationJob();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUserPermissions() {
|
||||
@ -102,8 +108,10 @@ export class ContextSrv {
|
||||
* Indicate the user has been logged out
|
||||
*/
|
||||
setLoggedOut() {
|
||||
this.cancelTokenRotationJob();
|
||||
this.user.isSignedIn = false;
|
||||
this.isSignedIn = false;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
hasRole(role: string) {
|
||||
@ -193,6 +201,80 @@ export class ContextSrv {
|
||||
// Hack to reject when user does not have permission
|
||||
return ['Reject'];
|
||||
}
|
||||
|
||||
// schedules a job to perform token ration in the background
|
||||
private scheduleTokenRotationJob() {
|
||||
// only schedule job if feature toggle is enabled and user is signed in
|
||||
if (config.featureToggles.clientTokenRotation && this.isSignedIn) {
|
||||
// get the time token is going to expire
|
||||
let expires = this.getSessionExpiry();
|
||||
|
||||
// if expires is 0 we run rotation now and reschedule the job
|
||||
// this can happen if user was signed in before upgrade
|
||||
// after a successful rotation the expiry cookie will be present
|
||||
if (expires === 0) {
|
||||
this.rotateToken().then();
|
||||
return;
|
||||
}
|
||||
|
||||
// because this job is scheduled for every tab we have open that shares a session we try
|
||||
// to distribute the scheduling of the job. For now this can be between 1 and 20 seconds
|
||||
const expiresWithDistribution = expires - Math.floor(Math.random() * (20 - 1) + 1);
|
||||
|
||||
// nextRun is when the job should be scheduled for
|
||||
let nextRun = expiresWithDistribution * 1000 - Date.now();
|
||||
|
||||
// @ts-ignore
|
||||
this.tokenRotationJobId = setTimeout(() => {
|
||||
// if we have a new expiry time from the expiry cookie another tab have already performed the rotation
|
||||
// so the only thing we need to do is reschedule the job and exit
|
||||
if (this.getSessionExpiry() > expires) {
|
||||
this.scheduleTokenRotationJob();
|
||||
return;
|
||||
}
|
||||
this.rotateToken().then();
|
||||
}, nextRun);
|
||||
}
|
||||
}
|
||||
|
||||
private cancelTokenRotationJob() {
|
||||
if (config.featureToggles.clientTokenRotation && this.tokenRotationJobId > 0) {
|
||||
clearTimeout(this.tokenRotationJobId);
|
||||
}
|
||||
}
|
||||
|
||||
private rotateToken() {
|
||||
// We directly use fetch here to bypass the request queue from backendSvc
|
||||
return fetch('/api/user/auth-tokens/rotate', { method: 'POST' })
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
this.scheduleTokenRotationJob();
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
this.setLoggedOut();
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
private getSessionExpiry() {
|
||||
const expiryCookie = document.cookie.split('; ').find((row) => row.startsWith('grafana_session_expiry='));
|
||||
if (!expiryCookie) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let expiresStr = expiryCookie.split('=').at(1);
|
||||
if (!expiresStr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return parseInt(expiresStr, 10);
|
||||
}
|
||||
}
|
||||
|
||||
let contextSrv = new ContextSrv();
|
||||
|
Loading…
Reference in New Issue
Block a user