mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Auth: Add feature flag to move token rotation to client (#65060)
* FeatureToggle: Add toggle to use a new way of rotating tokens * API: Add endpoints to perform token rotation, one endpoint for api request and one endpoint for redirectsd * Auth: Aling not authorized handling between auth middleware and access control middleware * API: add utility function to get redirect for login * API: Handle token rotation redirect for login page * Frontend: Add job scheduling for token rotation and make call to token rotation as fallback in retry request * ContextHandler: Prevent in-request rotation if feature flag is enabled and check if token needs to be rotated * AuthN: Prevent in-request rotation if feature flag is enabled and check if token needs to be rotated * Cookies: Add option NotHttpOnly * AuthToken: Add helper function to get next rotation time and another function to check if token need to be rotated * AuthN: Add function to delete session cookie and set expiry cookie Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
@@ -58,10 +58,19 @@ type RevokeAuthTokenCmd struct {
|
||||
AuthTokenId int64 `json:"authTokenId"`
|
||||
}
|
||||
|
||||
type RotateCommand struct {
|
||||
// token is the un-hashed token
|
||||
UnHashedToken string
|
||||
IP net.IP
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// UserTokenService are used for generating and validating user tokens
|
||||
type UserTokenService interface {
|
||||
CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*UserToken, error)
|
||||
LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error)
|
||||
// RotateToken will always rotate a valid token
|
||||
RotateToken(ctx context.Context, cmd RotateCommand) (*UserToken, error)
|
||||
TryRotateToken(ctx context.Context, token *UserToken, clientIP net.IP, userAgent string) (bool, *UserToken, error)
|
||||
RevokeToken(ctx context.Context, token *UserToken, soft bool) error
|
||||
RevokeAllUserTokens(ctx context.Context, userId int64) error
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -21,9 +23,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
const urgentRotateTime = 1 * time.Minute
|
||||
|
||||
var getTime = time.Now
|
||||
var (
|
||||
getTime = time.Now
|
||||
errTokenNotRotated = errors.New("token was not rotated")
|
||||
)
|
||||
|
||||
func ProvideUserAuthTokenService(sqlStore db.DB,
|
||||
serverLockService *serverlock.ServerLockService,
|
||||
@@ -62,13 +65,11 @@ type UserAuthTokenService struct {
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
|
||||
token, err := util.RandomHex(16)
|
||||
token, hashedToken, err := generateAndHashToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hashedToken := hashToken(token)
|
||||
|
||||
now := getTime().Unix()
|
||||
clientIPStr := clientIP.String()
|
||||
if len(clientIP) == 0 {
|
||||
@@ -150,17 +151,16 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
|
||||
// Current incoming token is the previous auth token in the DB and the auth_token_seen is true
|
||||
if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen {
|
||||
modelCopy := model
|
||||
modelCopy.AuthTokenSeen = false
|
||||
expireBefore := getTime().Add(-urgentRotateTime).Unix()
|
||||
model.AuthTokenSeen = false
|
||||
model.RotatedAt = getTime().Add(-usertoken.UrgentRotateTime).Unix()
|
||||
|
||||
var affectedRows int64
|
||||
err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
|
||||
affectedRows, err = dbSession.Where("id = ? AND prev_auth_token = ? AND rotated_at < ?",
|
||||
modelCopy.Id,
|
||||
modelCopy.PrevAuthToken,
|
||||
expireBefore).
|
||||
AllCols().Update(&modelCopy)
|
||||
model.Id,
|
||||
model.PrevAuthToken,
|
||||
model.RotatedAt).
|
||||
AllCols().Update(&model)
|
||||
|
||||
return err
|
||||
})
|
||||
@@ -178,16 +178,15 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
|
||||
// Current incoming token is not seen and it is the latest valid auth token in the db
|
||||
if !model.AuthTokenSeen && model.AuthToken == hashedToken {
|
||||
modelCopy := model
|
||||
modelCopy.AuthTokenSeen = true
|
||||
modelCopy.SeenAt = getTime().Unix()
|
||||
model.AuthTokenSeen = true
|
||||
model.SeenAt = getTime().Unix()
|
||||
|
||||
var affectedRows int64
|
||||
err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
|
||||
affectedRows, err = dbSession.Where("id = ? AND auth_token = ?",
|
||||
modelCopy.Id,
|
||||
modelCopy.AuthToken).
|
||||
AllCols().Update(&modelCopy)
|
||||
model.Id,
|
||||
model.AuthToken).
|
||||
AllCols().Update(&model)
|
||||
|
||||
return err
|
||||
})
|
||||
@@ -196,10 +195,6 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if affectedRows == 1 {
|
||||
model = modelCopy
|
||||
}
|
||||
|
||||
if affectedRows == 0 {
|
||||
ctxLogger.Debug("seen wrong token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
|
||||
} else {
|
||||
@@ -215,6 +210,90 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
||||
return &userToken, err
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
|
||||
if cmd.UnHashedToken == "" {
|
||||
return nil, auth.ErrInvalidSessionToken
|
||||
}
|
||||
|
||||
res, err, _ := s.singleflight.Do(cmd.UnHashedToken, func() (interface{}, error) {
|
||||
token, err := s.LookupToken(ctx, cmd.UnHashedToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newToken, err := s.rotateToken(ctx, token, cmd.IP, cmd.UserAgent)
|
||||
|
||||
if errors.Is(err, errTokenNotRotated) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newToken, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.(*auth.UserToken), nil
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
|
||||
var clientIPStr string
|
||||
if clientIP != nil {
|
||||
clientIPStr = clientIP.String()
|
||||
}
|
||||
|
||||
newToken, hashedToken, err := generateAndHashToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sql := `
|
||||
UPDATE user_auth_token
|
||||
SET
|
||||
seen_at = 0,
|
||||
user_agent = ?,
|
||||
client_ip = ?,
|
||||
prev_auth_token = auth_token,
|
||||
auth_token = ?,
|
||||
auth_token_seen = ?,
|
||||
rotated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
now := getTime()
|
||||
var affected int64
|
||||
err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
|
||||
res, err := dbSession.Exec(sql, userAgent, clientIPStr, hashedToken, s.sqlStore.GetDialect().BooleanStr(false), now.Unix(), token.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
affected, err = res.RowsAffected()
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if affected < 1 {
|
||||
return nil, errTokenNotRotated
|
||||
}
|
||||
|
||||
token.PrevAuthToken = token.AuthToken
|
||||
token.AuthToken = hashedToken
|
||||
token.UnhashedToken = newToken
|
||||
token.AuthTokenSeen = false
|
||||
token.RotatedAt = now.Unix()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.UserToken,
|
||||
clientIP net.IP, userAgent string) (bool, *auth.UserToken, error) {
|
||||
if token == nil {
|
||||
@@ -239,7 +318,7 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.U
|
||||
if model.AuthTokenSeen {
|
||||
needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute))
|
||||
} else {
|
||||
needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime))
|
||||
needsRotation = rotatedAt.Before(now.Add(-usertoken.UrgentRotateTime))
|
||||
}
|
||||
|
||||
if !needsRotation {
|
||||
@@ -504,11 +583,29 @@ func (s *UserAuthTokenService) rotatedAfterParam() int64 {
|
||||
return getTime().Add(-s.cfg.LoginMaxInactiveLifetime).Unix()
|
||||
}
|
||||
|
||||
func createToken() (string, error) {
|
||||
token, err := util.RandomHex(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
|
||||
return hex.EncodeToString(hashBytes[:])
|
||||
}
|
||||
|
||||
func generateAndHashToken() (string, string, error) {
|
||||
token, err := createToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return token, hashToken(token), nil
|
||||
}
|
||||
|
||||
func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
|
||||
limits := "a.Map{}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
@@ -311,7 +312,7 @@ func TestUserAuthToken(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, lookedUpUserToken)
|
||||
require.Equal(t, model.Id, lookedUpUserToken.Id)
|
||||
require.True(t, lookedUpUserToken.AuthTokenSeen)
|
||||
require.False(t, lookedUpUserToken.AuthTokenSeen)
|
||||
|
||||
getTime = func() time.Time {
|
||||
return now.Add(time.Hour + (2 * time.Minute))
|
||||
@@ -320,7 +321,7 @@ func TestUserAuthToken(t *testing.T) {
|
||||
lookedUpUserToken, err = ctx.tokenService.LookupToken(context.Background(), unhashedPrev)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, lookedUpUserToken)
|
||||
require.True(t, lookedUpUserToken.AuthTokenSeen)
|
||||
require.False(t, lookedUpUserToken.AuthTokenSeen)
|
||||
|
||||
lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
|
||||
require.Nil(t, err)
|
||||
@@ -390,7 +391,7 @@ func TestUserAuthToken(t *testing.T) {
|
||||
require.True(t, lookedUpModel.AuthTokenSeen)
|
||||
})
|
||||
|
||||
t.Run("Rotate token", func(t *testing.T) {
|
||||
t.Run("TryRotateToken", func(t *testing.T) {
|
||||
t.Run("Should rotate current token and previous token when auth token seen", func(t *testing.T) {
|
||||
getTime = func() time.Time { return now }
|
||||
userToken, err := ctx.tokenService.CreateToken(context.Background(), user,
|
||||
@@ -471,6 +472,59 @@ func TestUserAuthToken(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RotateToken", func(t *testing.T) {
|
||||
var prev string
|
||||
token, err := ctx.tokenService.CreateToken(context.Background(), user, nil, "")
|
||||
require.NoError(t, err)
|
||||
t.Run("should rotate token when called with current auth token", func(t *testing.T) {
|
||||
prev = token.UnhashedToken
|
||||
token, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: token.UnhashedToken})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, token.UnhashedToken != prev)
|
||||
assert.True(t, token.PrevAuthToken == hashToken(prev))
|
||||
})
|
||||
|
||||
t.Run("should rotate token when called with previous", func(t *testing.T) {
|
||||
newPrev := token.UnhashedToken
|
||||
token, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: prev})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, token.PrevAuthToken == hashToken(newPrev))
|
||||
})
|
||||
|
||||
t.Run("should not rotate token when called with old previous", func(t *testing.T) {
|
||||
_, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: prev})
|
||||
require.ErrorIs(t, err, auth.ErrUserTokenNotFound)
|
||||
})
|
||||
|
||||
t.Run("should return error when token is revoked", func(t *testing.T) {
|
||||
revokedToken, err := ctx.tokenService.CreateToken(context.Background(), user, nil, "")
|
||||
require.NoError(t, err)
|
||||
// mark token as revoked
|
||||
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
_, err := sess.Exec("UPDATE user_auth_token SET revoked_at = 1 WHERE id = ?", revokedToken.Id)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: revokedToken.UnhashedToken})
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidSessionToken)
|
||||
})
|
||||
|
||||
t.Run("should return error when token has expired", func(t *testing.T) {
|
||||
expiredToken, err := ctx.tokenService.CreateToken(context.Background(), user, nil, "")
|
||||
require.NoError(t, err)
|
||||
// mark token as expired
|
||||
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
_, err := sess.Exec("UPDATE user_auth_token SET created_at = 1 WHERE id = ?", expiredToken.Id)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ctx.tokenService.RotateToken(context.Background(), auth.RotateCommand{UnHashedToken: expiredToken.UnhashedToken})
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidSessionToken)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) {
|
||||
ut := auth.UserToken{
|
||||
Id: 1,
|
||||
|
||||
@@ -68,6 +68,5 @@ func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error {
|
||||
ut.UpdatedAt = uat.UpdatedAt
|
||||
ut.RevokedAt = uat.RevokedAt
|
||||
ut.UnhashedToken = uat.UnhashedToken
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
type FakeUserAuthTokenService struct {
|
||||
CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error)
|
||||
RotateTokenProvider func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error)
|
||||
TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error)
|
||||
LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error)
|
||||
RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error
|
||||
@@ -74,6 +75,10 @@ func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.U
|
||||
return s.CreateTokenProvider(context.Background(), user, clientIP, userAgent)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
|
||||
return s.RotateTokenProvider(ctx, cmd)
|
||||
}
|
||||
|
||||
func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
|
||||
return s.LookupTokenProvider(context.Background(), unhashedToken)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user