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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user