Auth: Attach external session info to Grafana session (#93849)

* initial from poc changes

* wip

* Remove public external session service

* Update swagger

* Fix merge

* Cleanup

* Add backgroud service for cleanup

* Add auth_module to user_external_session

* Add tests for token revocation functions

* Add secret migration capabilities for user_external_session fields

* Cleanup, refactor to address feedback

* Fix test
This commit is contained in:
Misi
2024-10-08 11:03:29 +02:00
committed by GitHub
parent 9eea0e99fc
commit bd7850853e
26 changed files with 1419 additions and 161 deletions
+115 -38
View File
@@ -14,10 +14,11 @@ 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/infra/tracing"
"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"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -28,10 +29,13 @@ var (
errUserIDInvalid = errors.New("invalid user ID")
)
var _ auth.UserTokenService = (*UserAuthTokenService)(nil)
func ProvideUserAuthTokenService(sqlStore db.DB,
serverLockService *serverlock.ServerLockService,
quotaService quota.Service,
cfg *setting.Cfg) (*UserAuthTokenService, error) {
quotaService quota.Service, secretService secrets.Service,
cfg *setting.Cfg, tracer tracing.Tracer,
) (*UserAuthTokenService, error) {
s := &UserAuthTokenService{
sqlStore: sqlStore,
serverLockService: serverLockService,
@@ -39,6 +43,7 @@ func ProvideUserAuthTokenService(sqlStore db.DB,
log: log.New("auth"),
singleflight: new(singleflight.Group),
}
s.externalSessionStore = provideExternalSessionStore(sqlStore, secretService, tracer)
defaultLimits, err := readQuotaConfig(cfg)
if err != nil {
@@ -57,31 +62,32 @@ func ProvideUserAuthTokenService(sqlStore db.DB,
}
type UserAuthTokenService struct {
sqlStore db.DB
serverLockService *serverlock.ServerLockService
cfg *setting.Cfg
log log.Logger
singleflight *singleflight.Group
sqlStore db.DB
serverLockService *serverlock.ServerLockService
cfg *setting.Cfg
log log.Logger
externalSessionStore auth.ExternalSessionStore
singleflight *singleflight.Group
}
func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
func (s *UserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
token, hashedToken, err := generateAndHashToken(s.cfg.SecretKey)
if err != nil {
return nil, err
}
now := getTime().Unix()
clientIPStr := clientIP.String()
if len(clientIP) == 0 {
clientIPStr := cmd.ClientIP.String()
if len(cmd.ClientIP) == 0 {
clientIPStr = ""
}
userAuthToken := userAuthToken{
UserId: user.ID,
UserId: cmd.User.ID,
AuthToken: hashedToken,
PrevAuthToken: hashedToken,
ClientIp: clientIPStr,
UserAgent: userAgent,
UserAgent: cmd.UserAgent,
RotatedAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -90,11 +96,21 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User,
AuthTokenSeen: false,
}
err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
_, err = dbSession.Insert(&userAuthToken)
return err
})
err = s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
if cmd.ExternalSession != nil {
inErr := s.externalSessionStore.Create(ctx, cmd.ExternalSession)
if inErr != nil {
return inErr
}
userAuthToken.ExternalSessionId = cmd.ExternalSession.ID
}
inErr := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
_, err := dbSession.Insert(&userAuthToken)
return err
})
return inErr
})
if err != nil {
return nil, err
}
@@ -164,7 +180,6 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
return err
})
if err != nil {
return nil, err
}
@@ -190,7 +205,6 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
return err
})
if err != nil {
return nil, err
}
@@ -210,6 +224,38 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
return &userToken, err
}
func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*auth.UserToken, error) {
var token userAuthToken
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.Where("external_session_id = ?", externalSessionID).Get(&token)
if err != nil {
return err
}
if !exists {
return auth.ErrUserTokenNotFound
}
return nil
})
if err != nil {
return nil, err
}
var userToken auth.UserToken
err = token.toUserToken(&userToken)
return &userToken, err
}
func (s *UserAuthTokenService) GetExternalSession(ctx context.Context, extSessionID int64) (*auth.ExternalSession, error) {
return s.externalSessionStore.Get(ctx, extSessionID)
}
func (s *UserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
return s.externalSessionStore.List(ctx, query)
}
func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
if cmd.UnHashedToken == "" {
return nil, auth.ErrInvalidSessionToken
@@ -277,7 +323,6 @@ func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.User
affected, err = res.RowsAffected()
return err
})
if err != nil {
return nil, err
}
@@ -305,6 +350,8 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
return err
}
ctxLogger := s.log.FromContext(ctx)
var rowsAffected int64
if soft {
@@ -324,7 +371,13 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
return err
}
ctxLogger := s.log.FromContext(ctx)
if model.ExternalSessionId != 0 {
err = s.externalSessionStore.Delete(ctx, model.ExternalSessionId)
if err != nil {
// Intentionally not returning error here, as the token has been revoked -> the backround job will clean up orphaned external sessions
ctxLogger.Warn("Failed to delete external session", "externalSessionID", model.ExternalSessionId, "err", err)
}
}
if rowsAffected == 0 {
ctxLogger.Debug("User auth token not found/revoked", "tokenID", model.Id, "userID", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
@@ -337,51 +390,75 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
}
func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error {
return s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
sql := `DELETE from user_auth_token WHERE user_id = ?`
res, err := dbSession.Exec(sql, userId)
return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
sql := `DELETE from user_auth_token WHERE user_id = ?`
res, err := dbSession.Exec(sql, userId)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
ctxLogger.Debug("All user tokens for user revoked", "userID", userId, "count", affected)
return nil
})
if err != nil {
return err
}
affected, err := res.RowsAffected()
err = s.externalSessionStore.DeleteExternalSessionsByUserID(ctx, userId)
if err != nil {
return err
// Intentionally not returning error here, as the token has been revoked -> the backround job will clean up orphaned external sessions
ctxLogger.Warn("Failed to delete external sessions for user", "userID", userId, "err", err)
}
s.log.FromContext(ctx).Debug("All user tokens for user revoked", "userID", userId, "count", affected)
return err
return nil
})
}
func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error {
return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
if len(userIds) == 0 {
return nil
}
user_id_params := strings.Repeat(",?", len(userIds)-1)
sql := "DELETE from user_auth_token WHERE user_id IN (?" + user_id_params + ")"
userIdParams := strings.Repeat(",?", len(userIds)-1)
sql := "DELETE from user_auth_token WHERE user_id IN (?" + userIdParams + ")"
params := []any{sql}
for _, v := range userIds {
params = append(params, v)
}
res, err := dbSession.Exec(params...)
var affected int64
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
res, inErr := dbSession.Exec(params...)
if inErr != nil {
return inErr
}
affected, inErr = res.RowsAffected()
return inErr
})
if err != nil {
return err
}
affected, err := res.RowsAffected()
err = s.externalSessionStore.BatchDeleteExternalSessionsByUserIDs(ctx, userIds)
if err != nil {
return err
ctxLogger.Warn("Failed to delete external sessions for users", "users", userIds, "err", err)
}
s.log.FromContext(ctx).Debug("All user tokens for given users revoked", "usersCount", len(userIds), "count", affected)
ctxLogger.Debug("All user tokens for given users revoked", "usersCount", len(userIds), "count", affected)
return err
return nil
})
}
+306 -52
View File
@@ -3,20 +3,25 @@ package authimpl
import (
"context"
"encoding/json"
"errors"
"net"
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"golang.org/x/sync/singleflight"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
@@ -36,8 +41,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("When creating token", func(t *testing.T) {
createToken := func() *auth.UserToken {
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr,
net.ParseIP("192.168.10.11"), "some user agent")
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
require.NotNil(t, userToken)
require.False(t, userToken.AuthTokenSeen)
@@ -109,8 +117,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
userToken = createToken()
t.Run("When creating an additional token", func(t *testing.T) {
userToken2, err := ctx.tokenService.CreateToken(context.Background(), usr,
net.ParseIP("192.168.10.11"), "some user agent")
userToken2, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
require.NotNil(t, userToken2)
@@ -156,8 +167,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
for i := 0; i < 3; i++ {
userId := usr.ID + int64(i+1)
userIds = append(userIds, userId)
_, err := ctx.tokenService.CreateToken(context.Background(), usr,
net.ParseIP("192.168.10.11"), "some user agent")
_, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
}
@@ -173,10 +187,89 @@ func TestIntegrationUserAuthToken(t *testing.T) {
})
})
t.Run("When creating token with external session", func(t *testing.T) {
createToken := func() *auth.UserToken {
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
require.False(t, userToken.AuthTokenSeen)
return userToken
}
userToken := createToken()
t.Run("soft revoking existing token should remove the associated external session", func(t *testing.T) {
err := ctx.tokenService.RevokeToken(context.Background(), userToken, true)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.NotNil(t, model)
require.Greater(t, model.RevokedAt, int64(0))
extSess, err := ctx.getExternalSessionByID(userToken.ExternalSessionId)
require.Nil(t, err)
require.Nil(t, extSess)
})
t.Run("revoking existing token should also remove the associated external session", func(t *testing.T) {
err := ctx.tokenService.RevokeToken(context.Background(), userToken, false)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.Nil(t, model)
extSess, err := ctx.getExternalSessionByID(userToken.ExternalSessionId)
require.Nil(t, err)
require.Nil(t, extSess)
})
t.Run("When revoking users tokens in a batch", func(t *testing.T) {
t.Run("Can revoke all users tokens and associated external sessions", func(t *testing.T) {
userIds := []int64{}
extSessionIds := []int64{}
for i := 0; i < 3; i++ {
userId := usr.ID + int64(i+1)
userIds = append(userIds, userId)
token, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: userId, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
extSessionIds = append(extSessionIds, token.ExternalSessionId)
}
err := ctx.tokenService.BatchRevokeAllUserTokens(context.Background(), userIds)
require.Nil(t, err)
for i := 0; i < len(userIds); i++ {
tokens, err := ctx.tokenService.GetUserTokens(context.Background(), userIds[i])
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
extSess, err := ctx.getExternalSessionByID(extSessionIds[i])
require.Nil(t, err)
require.Nil(t, extSess)
}
})
})
})
t.Run("expires correctly", func(t *testing.T) {
ctx := createTestContext(t)
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr,
net.ParseIP("192.168.10.11"), "some user agent")
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
userToken, err = ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken)
@@ -262,7 +355,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("can properly rotate tokens", func(t *testing.T) {
getTime = func() time.Time { return now }
ctx := createTestContext(t)
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr, net.ParseIP("192.168.10.11"), "some user agent")
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
prevToken := userToken.AuthToken
@@ -335,8 +432,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("keeps prev token valid for 1 minute after it is confirmed", func(t *testing.T) {
getTime = func() time.Time { return now }
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr,
net.ParseIP("192.168.10.11"), "some user agent")
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
require.NotNil(t, userToken)
@@ -368,8 +468,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
})
t.Run("will not mark token unseen when prev and current are the same", func(t *testing.T) {
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr,
net.ParseIP("192.168.10.11"), "some user agent")
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
require.NotNil(t, userToken)
@@ -389,7 +492,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("RotateToken", func(t *testing.T) {
var prev string
token, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "")
token, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err)
t.Run("should rotate token when called with current auth token", func(t *testing.T) {
prev = token.UnhashedToken
@@ -412,7 +519,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
})
t.Run("should return error when token is revoked", func(t *testing.T) {
revokedToken, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "")
revokedToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err)
// mark token as revoked
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
@@ -426,7 +537,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
})
t.Run("should return error when token has expired", func(t *testing.T) {
expiredToken, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "")
expiredToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err)
// mark token as expired
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
@@ -441,10 +556,18 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("should only delete revoked tokens that are outside on specified window", func(t *testing.T) {
usr := &user.User{ID: 100}
token1, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "")
token1, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err)
token2, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "")
token2, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err)
getTime = func() time.Time {
@@ -474,18 +597,19 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) {
ut := auth.UserToken{
Id: 1,
UserId: 2,
AuthToken: "a",
PrevAuthToken: "b",
UserAgent: "c",
ClientIp: "d",
AuthTokenSeen: true,
SeenAt: 3,
RotatedAt: 4,
CreatedAt: 5,
UpdatedAt: 6,
UnhashedToken: "e",
Id: 1,
UserId: 2,
AuthToken: "a",
PrevAuthToken: "b",
UserAgent: "c",
ClientIp: "d",
AuthTokenSeen: true,
SeenAt: 3,
RotatedAt: 4,
CreatedAt: 5,
UpdatedAt: 6,
UnhashedToken: "e",
ExternalSessionId: 7,
}
utBytes, err := json.Marshal(ut)
require.Nil(t, err)
@@ -507,18 +631,19 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("When populating userToken from userAuthToken should copy all properties", func(t *testing.T) {
uat := userAuthToken{
Id: 1,
UserId: 2,
AuthToken: "a",
PrevAuthToken: "b",
UserAgent: "c",
ClientIp: "d",
AuthTokenSeen: true,
SeenAt: 3,
RotatedAt: 4,
CreatedAt: 5,
UpdatedAt: 6,
UnhashedToken: "e",
Id: 1,
UserId: 2,
AuthToken: "a",
PrevAuthToken: "b",
UserAgent: "c",
ClientIp: "d",
AuthTokenSeen: true,
SeenAt: 3,
RotatedAt: 4,
CreatedAt: 5,
UpdatedAt: 6,
UnhashedToken: "e",
ExternalSessionId: 7,
}
uatBytes, err := json.Marshal(uat)
require.Nil(t, err)
@@ -551,22 +676,27 @@ func createTestContext(t *testing.T) *testContext {
TokenRotationIntervalMinutes: 10,
}
extSessionStore := provideExternalSessionStore(sqlstore, &fakes.FakeSecretsService{}, tracing.InitializeTracerForTest())
tokenService := &UserAuthTokenService{
sqlStore: sqlstore,
cfg: cfg,
log: log.New("test-logger"),
singleflight: new(singleflight.Group),
sqlStore: sqlstore,
cfg: cfg,
log: log.New("test-logger"),
singleflight: new(singleflight.Group),
externalSessionStore: extSessionStore,
}
return &testContext{
sqlstore: sqlstore,
tokenService: tokenService,
sqlstore: sqlstore,
tokenService: tokenService,
extSessionStore: &extSessionStore,
}
}
type testContext struct {
sqlstore db.DB
tokenService *UserAuthTokenService
sqlstore db.DB
tokenService *UserAuthTokenService
extSessionStore *auth.ExternalSessionStore
}
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
@@ -585,6 +715,22 @@ func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
return res, err
}
func (c *testContext) getExternalSessionByID(ID int64) (*auth.ExternalSession, error) {
var res *auth.ExternalSession
err := c.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
var t auth.ExternalSession
found, err := sess.ID(ID).Get(&t)
if err != nil || !found {
return err
}
res = &t
return nil
})
return res, err
}
func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) {
hasRowsAffected := false
err := c.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
@@ -609,8 +755,11 @@ func TestIntegrationTokenCount(t *testing.T) {
user := &user.User{ID: int64(10)}
createToken := func() *auth.UserToken {
userToken, err := ctx.tokenService.CreateToken(context.Background(), user,
net.ParseIP("192.168.10.11"), "some user agent")
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: user,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err)
require.NotNil(t, userToken)
require.False(t, userToken.AuthTokenSeen)
@@ -637,3 +786,108 @@ func TestIntegrationTokenCount(t *testing.T) {
require.Nil(t, err)
require.Equal(t, int64(0), count)
}
func TestRevokeAllUserTokens(t *testing.T) {
t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) {
ctx := createTestContext(t)
usr := &user.User{ID: int64(10)}
// Mock the external session store to return an error
mockExternalSessionStore := &authtest.MockExternalSessionStore{}
mockExternalSessionStore.On("Create", mock.Anything, mock.IsType(&auth.ExternalSession{})).Run(func(args mock.Arguments) {
extSession := args.Get(1).(*auth.ExternalSession)
extSession.ID = 1
}).Return(nil)
mockExternalSessionStore.On("DeleteExternalSessionsByUserID", mock.Anything, usr.ID).Return(errors.New("some error"))
ctx.tokenService.externalSessionStore = mockExternalSessionStore
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
err = ctx.tokenService.RevokeAllUserTokens(context.Background(), usr.ID)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.Nil(t, model)
})
}
func TestRevokeToken(t *testing.T) {
t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) {
ctx := createTestContext(t)
usr := &user.User{ID: int64(10)}
mockExternalSessionStore := &authtest.MockExternalSessionStore{}
mockExternalSessionStore.On("Create", mock.Anything, mock.IsType(&auth.ExternalSession{})).Run(func(args mock.Arguments) {
extSession := args.Get(1).(*auth.ExternalSession)
extSession.ID = 2
}).Return(nil)
mockExternalSessionStore.On("Delete", mock.Anything, int64(2)).Return(errors.New("some error"))
ctx.tokenService.externalSessionStore = mockExternalSessionStore
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
err = ctx.tokenService.RevokeToken(context.Background(), userToken, false)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.Nil(t, model)
})
}
func TestBatchRevokeAllUserTokens(t *testing.T) {
t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) {
ctx := createTestContext(t)
userIds := []int64{1, 2, 3}
mockExternalSessionStore := &authtest.MockExternalSessionStore{}
mockExternalSessionStore.On("BatchDeleteExternalSessionsByUserIDs", mock.Anything, userIds).Return(errors.New("some error"))
ctr := int64(0)
mockExternalSessionStore.On("Create", mock.Anything, mock.IsType(&auth.ExternalSession{})).Run(func(args mock.Arguments) {
extSession := args.Get(1).(*auth.ExternalSession)
ctr += 1
extSession.ID = ctr
}).Return(nil)
ctx.tokenService.externalSessionStore = mockExternalSessionStore
for _, userID := range userIds {
usr := &user.User{ID: userID}
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
}
// Batch revoke all user tokens
err := ctx.tokenService.BatchRevokeAllUserTokens(context.Background(), userIds)
require.Nil(t, err)
// Verify that the tokens have been revoked
for _, userID := range userIds {
tokens, err := ctx.tokenService.GetUserTokens(context.Background(), userID)
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
}
})
}
@@ -0,0 +1,244 @@
package authimpl
import (
"context"
"crypto/sha256"
"encoding/base64"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/secrets"
)
var _ auth.ExternalSessionStore = (*store)(nil)
type store struct {
sqlStore db.DB
secretsService secrets.Service
tracer tracing.Tracer
}
func provideExternalSessionStore(sqlStore db.DB, secretService secrets.Service, tracer tracing.Tracer) auth.ExternalSessionStore {
return &store{
sqlStore: sqlStore,
secretsService: secretService,
tracer: tracer,
}
}
func (s *store) Get(ctx context.Context, extSessionID int64) (*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "externalsession.Get")
defer span.End()
externalSession := &auth.ExternalSession{ID: extSessionID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
found, err := sess.Get(externalSession)
if err != nil {
return err
}
if !found {
return auth.ErrExternalSessionNotFound
}
return nil
})
if err != nil {
return nil, err
}
err = s.decryptSecrets(externalSession)
if err != nil {
return nil, err
}
return externalSession, nil
}
func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "externalsession.List")
defer span.End()
externalSession := &auth.ExternalSession{}
if query.ID != 0 {
externalSession.ID = query.ID
}
hash := sha256.New()
if query.SessionID != "" {
hash.Write([]byte(query.SessionID))
externalSession.SessionIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
if query.NameID != "" {
hash.Reset()
hash.Write([]byte(query.NameID))
externalSession.NameIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
queryResult := make([]*auth.ExternalSession, 0)
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
return sess.Find(&queryResult, externalSession)
})
if err != nil {
return nil, err
}
for _, extSession := range queryResult {
err := s.decryptSecrets(extSession)
if err != nil {
return nil, err
}
}
return queryResult, nil
}
func (s *store) Create(ctx context.Context, extSession *auth.ExternalSession) error {
ctx, span := s.tracer.Start(ctx, "externalsession.Create")
defer span.End()
var err error
clone := extSession.Clone()
clone.AccessToken, err = s.encryptAndEncode(extSession.AccessToken)
if err != nil {
return err
}
clone.RefreshToken, err = s.encryptAndEncode(extSession.RefreshToken)
if err != nil {
return err
}
clone.IDToken, err = s.encryptAndEncode(extSession.IDToken)
if err != nil {
return err
}
if extSession.NameID != "" {
hash := sha256.New()
hash.Write([]byte(extSession.NameID))
clone.NameIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
clone.NameID, err = s.encryptAndEncode(extSession.NameID)
if err != nil {
return err
}
if extSession.SessionID != "" {
hash := sha256.New()
hash.Write([]byte(extSession.SessionID))
clone.SessionIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
clone.SessionID, err = s.encryptAndEncode(extSession.SessionID)
if err != nil {
return err
}
err = s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Insert(clone)
return err
})
if err != nil {
return err
}
extSession.ID = clone.ID
return nil
}
func (s *store) Delete(ctx context.Context, ID int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.Delete")
defer span.End()
externalSession := &auth.ExternalSession{ID: ID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Delete(externalSession)
return err
})
return err
}
func (s *store) DeleteExternalSessionsByUserID(ctx context.Context, userID int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.DeleteExternalSessionsByUserID")
defer span.End()
externalSession := &auth.ExternalSession{UserID: userID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Delete(externalSession)
return err
})
return err
}
func (s *store) BatchDeleteExternalSessionsByUserIDs(ctx context.Context, userIDs []int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.BatchDeleteExternalSessionsByUserIDs")
defer span.End()
externalSession := &auth.ExternalSession{}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.In("user_id", userIDs).Delete(externalSession)
return err
})
return err
}
func (s *store) decryptSecrets(extSession *auth.ExternalSession) error {
var err error
extSession.AccessToken, err = s.decodeAndDecrypt(extSession.AccessToken)
if err != nil {
return err
}
extSession.RefreshToken, err = s.decodeAndDecrypt(extSession.RefreshToken)
if err != nil {
return err
}
extSession.IDToken, err = s.decodeAndDecrypt(extSession.IDToken)
if err != nil {
return err
}
extSession.NameID, err = s.decodeAndDecrypt(extSession.NameID)
if err != nil {
return err
}
extSession.SessionID, err = s.decodeAndDecrypt(extSession.SessionID)
if err != nil {
return err
}
return nil
}
func (s *store) encryptAndEncode(str string) (string, error) {
if str == "" {
return "", nil
}
encrypted, err := s.secretsService.Encrypt(context.Background(), []byte(str), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
func (s *store) decodeAndDecrypt(str string) (string, error) {
// Bail out if empty string since it'll cause a segfault in Decrypt
if str == "" {
return "", nil
}
decoded, err := base64.StdEncoding.DecodeString(str)
if err != nil {
return "", err
}
decrypted, err := s.secretsService.Decrypt(context.Background(), decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
@@ -0,0 +1,228 @@
package authimpl
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/stretchr/testify/require"
)
func TestGetExternalSession(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("returns existing external session", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
AccessToken: "access-token",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
actual, err := store.Get(context.Background(), extSession.ID)
require.NoError(t, err)
require.EqualValues(t, extSession.ID, actual.ID)
require.EqualValues(t, extSession.AccessToken, actual.AccessToken)
})
t.Run("returns not found if the external session is missing", func(t *testing.T) {
store := setupTest(t)
_, err := store.Get(context.Background(), 999)
require.ErrorIs(t, err, auth.ErrExternalSessionNotFound)
})
}
func TestFindExternalSessions(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("returns external sessions by ID", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
AccessToken: "access-token",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{ID: extSession.ID}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 1)
require.EqualValues(t, extSession.ID, actual[0].ID)
require.EqualValues(t, extSession.AccessToken, actual[0].AccessToken)
})
t.Run("returns external sessions by SessionID", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
SessionID: "session-index",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{SessionID: extSession.SessionID}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 1)
require.EqualValues(t, extSession.ID, actual[0].ID)
require.EqualValues(t, extSession.SessionID, actual[0].SessionID)
})
t.Run("returns external sessions by NameID", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
NameID: "name-id",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{NameID: extSession.NameID}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 1)
require.EqualValues(t, extSession.ID, actual[0].ID)
require.EqualValues(t, extSession.NameID, actual[0].NameID)
})
t.Run("returns empty result if no external sessions match the query", func(t *testing.T) {
store := setupTest(t)
query := &auth.ListExternalSessionQuery{ID: 999}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 0)
})
}
func TestDeleteExternalSessionsByUserID(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("deletes all external sessions for a given user ID", func(t *testing.T) {
store := setupTest(t)
userID := int64(1)
extSession1 := &auth.ExternalSession{
UserID: userID,
AccessToken: "access-token-1",
}
extSession2 := &auth.ExternalSession{
UserID: userID,
AccessToken: "access-token-2",
}
err := store.Create(context.Background(), extSession1)
require.NoError(t, err)
err = store.Create(context.Background(), extSession2)
require.NoError(t, err)
err = store.DeleteExternalSessionsByUserID(context.Background(), userID)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 0)
})
t.Run("returns no error if no external sessions exist for the given user ID", func(t *testing.T) {
store := setupTest(t)
userID := int64(999)
err := store.DeleteExternalSessionsByUserID(context.Background(), userID)
require.NoError(t, err)
})
}
func TestDeleteExternalSession(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("deletes an existing external session", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
AccessToken: "access-token",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
err = store.Delete(context.Background(), extSession.ID)
require.NoError(t, err)
_, err = store.Get(context.Background(), extSession.ID)
require.ErrorIs(t, err, auth.ErrExternalSessionNotFound)
})
t.Run("returns no error if the external session does not exist", func(t *testing.T) {
store := setupTest(t)
err := store.Delete(context.Background(), 999)
require.NoError(t, err)
})
}
func TestBatchDeleteExternalSessionsByUserIDs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("deletes all external sessions for given user IDs", func(t *testing.T) {
store := setupTest(t)
userID1 := int64(1)
userID2 := int64(2)
extSession1 := &auth.ExternalSession{
UserID: userID1,
AccessToken: "access-token-1",
}
extSession2 := &auth.ExternalSession{
UserID: userID2,
AccessToken: "access-token-2",
}
err := store.Create(context.Background(), extSession1)
require.NoError(t, err)
err = store.Create(context.Background(), extSession2)
require.NoError(t, err)
err = store.BatchDeleteExternalSessionsByUserIDs(context.Background(), []int64{userID1, userID2})
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 0)
})
t.Run("returns no error if no external sessions exist for the given user IDs", func(t *testing.T) {
store := setupTest(t)
err := store.BatchDeleteExternalSessionsByUserIDs(context.Background(), []int64{999, 1000})
require.NoError(t, err)
})
}
func setupTest(t *testing.T) *store {
sqlStore := db.InitTestDB(t)
secretService := fakes.NewFakeSecretsService()
tracer := tracing.InitializeTracerForTest()
externalSessionStore := provideExternalSessionStore(sqlStore, secretService, tracer).(*store)
return externalSessionStore
}
+16 -13
View File
@@ -7,19 +7,20 @@ import (
)
type userAuthToken struct {
Id int64
UserId int64
AuthToken string
PrevAuthToken string
UserAgent string
ClientIp string
AuthTokenSeen bool
SeenAt int64
RotatedAt int64
CreatedAt int64
UpdatedAt int64
RevokedAt int64
UnhashedToken string `xorm:"-"`
Id int64
UserId int64
AuthToken string
PrevAuthToken string
UserAgent string
ClientIp string
AuthTokenSeen bool
SeenAt int64
RotatedAt int64
CreatedAt int64
UpdatedAt int64
RevokedAt int64
UnhashedToken string `xorm:"-"`
ExternalSessionId int64
}
func userAuthTokenFromUserToken(ut *auth.UserToken) (*userAuthToken, error) {
@@ -46,6 +47,7 @@ func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) error {
uat.UpdatedAt = ut.UpdatedAt
uat.RevokedAt = ut.RevokedAt
uat.UnhashedToken = ut.UnhashedToken
uat.ExternalSessionId = ut.ExternalSessionId
return nil
}
@@ -68,5 +70,6 @@ func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error {
ut.UpdatedAt = uat.UpdatedAt
ut.RevokedAt = uat.RevokedAt
ut.UnhashedToken = uat.UnhashedToken
ut.ExternalSessionId = uat.ExternalSessionId
return nil
}
@@ -16,6 +16,9 @@ func (s *UserAuthTokenService) Run(ctx context.Context) error {
if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil {
s.log.Error("An error occurred while deleting expired tokens", "err", err)
}
if err := s.deleteOrphanedExternalSessions(ctx); err != nil {
s.log.Error("An error occurred while deleting orphaned external sessions", "err", err)
}
})
if err != nil {
s.log.Error("Failed to lock and execute cleanup of expired auth token", "error", err)
@@ -28,6 +31,9 @@ func (s *UserAuthTokenService) Run(ctx context.Context) error {
if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil {
s.log.Error("An error occurred while deleting expired tokens", "err", err)
}
if err := s.deleteOrphanedExternalSessions(ctx); err != nil {
s.log.Error("An error occurred while deleting orphaned external sessions", "err", err)
}
})
if err != nil {
s.log.Error("Failed to lock and execute cleanup of expired auth token", "error", err)
@@ -66,3 +72,29 @@ func (s *UserAuthTokenService) deleteExpiredTokens(ctx context.Context, maxInact
return affected, err
}
func (s *UserAuthTokenService) deleteOrphanedExternalSessions(ctx context.Context) error {
s.log.Debug("Starting cleanup of external sessions")
var affected int64
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
sql := `DELETE FROM user_external_session WHERE NOT EXISTS (SELECT 1 FROM user_auth_token WHERE user_external_session.id = user_auth_token.external_session_id)`
res, err := dbSession.Exec(sql)
if err != nil {
return err
}
affected, err = res.RowsAffected()
if err != nil {
s.log.Error("Failed to cleanup orphaned external sessions", "error", err)
return nil
}
s.log.Debug("Cleanup of orphaned external sessions done", "count", affected)
return nil
})
return err
}
@@ -9,9 +9,14 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/auth"
)
func TestUserAuthTokenCleanup(t *testing.T) {
func TestIntegrationUserAuthTokenCleanup(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
setup := func() *testContext {
ctx := createTestContext(t)
maxInactiveLifetime, _ := time.ParseDuration("168h")
@@ -75,3 +80,61 @@ func TestUserAuthTokenCleanup(t *testing.T) {
require.Equal(t, int64(3), affected)
})
}
func TestIntegrationOrphanedExternalSessionsCleanup(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
setup := func() *testContext {
ctx := createTestContext(t)
return ctx
}
insertExternalSession := func(ctx *testContext, id int64) {
es := &auth.ExternalSession{ID: id, UserAuthID: 1, UserID: 1}
err := ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Insert(es)
require.Nil(t, err)
return nil
})
require.NoError(t, err)
}
insertAuthToken := func(ctx *testContext, token string, externalSessionId int64) {
ut := userAuthToken{AuthToken: token, PrevAuthToken: fmt.Sprintf("old%s", token), ExternalSessionId: externalSessionId}
err := ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Insert(&ut)
require.Nil(t, err)
return nil
})
require.NoError(t, err)
}
t.Run("should delete orphaned external sessions", func(t *testing.T) {
ctx := setup()
// insert three external sessions
for i := int64(1); i <= 3; i++ {
insertExternalSession(ctx, i)
}
// insert two auth tokens linked to external sessions
insertAuthToken(ctx, "token1", 1)
insertAuthToken(ctx, "token2", 2)
// delete orphaned external sessions
err := ctx.tokenService.deleteOrphanedExternalSessions(context.Background())
require.NoError(t, err)
// verify that only the orphaned external session is deleted
var count int64
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
count, err = sess.Count(&auth.ExternalSession{})
require.Nil(t, err)
return nil
})
require.NoError(t, err)
require.Equal(t, int64(2), count)
})
}