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:
Karl Persson 2023-03-23 14:39:04 +01:00 committed by GitHub
parent d13488a435
commit 382b24742a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 813 additions and 261 deletions

View File

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

View File

@ -82,4 +82,5 @@ export interface FeatureToggles {
prometheusMetricEncyclopedia?: boolean;
timeSeriesTable?: boolean;
influxdbBackendMigration?: boolean;
clientTokenRotation?: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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 := &quota.Map{}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
63 prometheusMetricEncyclopedia alpha @grafana/observability-metrics false false false true
64 timeSeriesTable alpha @grafana/app-o11y false false false true
65 influxdbBackendMigration alpha @grafana/observability-metrics false false false true
66 clientTokenRotation alpha @grafana/grafana-authnz-team false false false false

View File

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

View File

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

View File

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