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

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