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
30 changed files with 813 additions and 261 deletions
+22 -5
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
})
}
+8 -1
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
+19 -16
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)
}
})
+99 -63
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)
})
}
+1
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")