Anon: Scaffold anon service (#74744)

* remove API tagging method and authed tagging

* add anonstore

move debug to after cache

change test order

fix issue where mysql trims to second

* add old device cleanup

lint

utc-ize everything

trim whitespace

* remove dangling setting

* Add delete devices

* Move anonymous authnclient to anonimpl

* Add simple post login hook

* move registration of Background Service

cleanup

* add updated_at index

* do not untag device if login err

* add delete device integration test
This commit is contained in:
Jo 2023-09-25 16:25:29 +02:00 committed by GitHub
parent 6f665b0901
commit 40a1f8434d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 429 additions and 434 deletions

View File

@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
@ -54,6 +55,7 @@ func ProvideBackgroundServiceRegistry(
publicDashboardsMetric *publicdashboardsmetric.Service,
keyRetriever *dynamic.KeyRetriever,
dynamicAngularDetectorsProvider *angulardetectorsprovider.Dynamic,
anon *anonimpl.AnonDeviceService,
// Need to make sure these are initialized, is there a better place to put them?
_ dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
_ serviceaccounts.Service, _ *guardian.Provider,
@ -92,6 +94,7 @@ func ProvideBackgroundServiceRegistry(
publicDashboardsMetric,
keyRetriever,
dynamicAngularDetectorsProvider,
anon,
)
}

View File

@ -8,6 +8,7 @@ package server
import (
"github.com/google/wire"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/api"
@ -39,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/annotations/annotationsimpl"
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
"github.com/grafana/grafana/pkg/services/apikey/apikeyimpl"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/authn/authnimpl"
@ -351,6 +353,8 @@ var wireBasicSet = wire.NewSet(
authnimpl.ProvideAuthnService,
supportbundlesimpl.ProvideService,
oasimpl.ProvideService,
anonstore.ProvideAnonDBStore,
wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)),
wire.Bind(new(oauthserver.OAuth2Server), new(*oasimpl.OAuth2ServiceImpl)),
loggermw.Provide,
signingkeysimpl.ProvideEmbeddedSigningKeysService,

View File

@ -0,0 +1,126 @@
package anonstore
import (
"context"
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
const cacheKeyPrefix = "anon-device"
type AnonDBStore struct {
sqlStore db.DB
log log.Logger
}
type Device struct {
ID int64 `json:"-" db:"id"`
DeviceID string `json:"device_id" db:"device_id"`
ClientIP string `json:"client_ip" db:"client_ip"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
func (a *Device) CacheKey() string {
return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":")
}
type AnonStore interface {
// ListDevices returns all devices that have been updated between the given times.
ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error)
// CreateOrUpdateDevice creates or updates a device.
CreateOrUpdateDevice(ctx context.Context, device *Device) error
// CountDevices returns the number of devices that have been updated between the given times.
CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error)
// DeleteDevice deletes a device by its ID.
DeleteDevice(ctx context.Context, deviceID string) error
// DeleteDevicesOlderThan deletes all devices that have no been updated since the given time.
DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error
}
func ProvideAnonDBStore(sqlStore db.DB) *AnonDBStore {
return &AnonDBStore{sqlStore: sqlStore, log: log.New("anonstore")}
}
func (s *AnonDBStore) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error) {
devices := []*Device{}
query := "SELECT * FROM anon_device"
args := []any{}
if from != nil && to != nil {
query += " WHERE updated_at BETWEEN ? AND ?"
args = append(args, from.UTC(), to.UTC())
}
err := s.sqlStore.GetSqlxSession().Select(ctx, &devices, query, args...)
if err != nil {
return nil, err
}
return devices, nil
}
func (s *AnonDBStore) CreateOrUpdateDevice(ctx context.Context, device *Device) error {
var query string
args := []any{device.DeviceID, device.ClientIP, device.UserAgent,
device.CreatedAt.UTC(), device.UpdatedAt.UTC()}
switch s.sqlStore.GetDBType() {
case migrator.Postgres:
query = `INSERT INTO anon_device (device_id, client_ip, user_agent, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (device_id) DO UPDATE SET
client_ip = $2,
user_agent = $3,
updated_at = $5
RETURNING id`
case migrator.MySQL:
query = `INSERT INTO anon_device (device_id, client_ip, user_agent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
client_ip = VALUES(client_ip),
user_agent = VALUES(user_agent),
updated_at = VALUES(updated_at)`
case migrator.SQLite:
query = `INSERT INTO anon_device (device_id, client_ip, user_agent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (device_id) DO UPDATE SET
client_ip = excluded.client_ip,
user_agent = excluded.user_agent,
updated_at = excluded.updated_at`
default:
return fmt.Errorf("unsupported database driver: %s", s.sqlStore.GetDBType())
}
_, err := s.sqlStore.GetSqlxSession().Exec(ctx, query, args...)
return err
}
func (s *AnonDBStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
var count int64
err := s.sqlStore.GetSqlxSession().Get(ctx, &count, "SELECT COUNT(*) FROM anon_device WHERE updated_at BETWEEN ? AND ?", from.UTC(), to.UTC())
if err != nil {
return 0, err
}
return count, nil
}
func (s *AnonDBStore) DeleteDevice(ctx context.Context, deviceID string) error {
_, err := s.sqlStore.GetSqlxSession().Exec(ctx, "DELETE FROM anon_device WHERE device_id = ?", deviceID)
if err != nil {
return err
}
return nil
}
// deleteOldDevices deletes all devices that have no been updated since the given time.
func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error {
_, err := s.sqlStore.GetSqlxSession().Exec(ctx, "DELETE FROM anon_device WHERE updated_at <= ?", olderThan.UTC())
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,79 @@
package anonstore
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
)
func TestIntegrationAnonStore_DeleteDevicesOlderThan(t *testing.T) {
store := db.InitTestDB(t)
anonDBStore := ProvideAnonDBStore(store)
const keepFor = time.Hour * 24 * 61
anonDevice := &Device{
DeviceID: "32mdo31deeqwes",
ClientIP: "10.30.30.2",
UserAgent: "test",
UpdatedAt: time.Now().Add(-keepFor).Add(-time.Hour),
}
err := anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
require.NoError(t, err)
anonDevice.DeviceID = "keep"
anonDevice.UpdatedAt = time.Now().Add(-time.Hour)
err = anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
require.NoError(t, err)
from := time.Now().Add(-2 * keepFor)
to := time.Now()
count, err := anonDBStore.CountDevices(context.Background(), from, to)
require.NoError(t, err)
require.Equal(t, int64(2), count)
err = anonDBStore.DeleteDevicesOlderThan(context.Background(), time.Now().Add(-keepFor))
require.NoError(t, err)
devices, err := anonDBStore.ListDevices(context.Background(), &from, &to)
require.NoError(t, err)
require.Equal(t, 1, len(devices))
assert.Equal(t, "keep", devices[0].DeviceID)
}
func TestIntegrationAnonStore_DeleteDevice(t *testing.T) {
store := db.InitTestDB(t)
anonDBStore := ProvideAnonDBStore(store)
const keepFor = time.Hour * 24 * 61
anonDevice := &Device{
DeviceID: "32mdo31deeqwes",
ClientIP: "10.30.30.2",
UserAgent: "test",
UpdatedAt: time.Now().Add(-keepFor).Add(-time.Hour),
}
err := anonDBStore.CreateOrUpdateDevice(context.Background(), anonDevice)
require.NoError(t, err)
from := time.Now().Add(-2 * keepFor)
to := time.Now()
count, err := anonDBStore.CountDevices(context.Background(), from, to)
require.NoError(t, err)
require.Equal(t, int64(1), count)
err = anonDBStore.DeleteDevice(context.Background(), "32mdo31deeqwes")
require.NoError(t, err)
devices, err := anonDBStore.ListDevices(context.Background(), &from, &to)
require.NoError(t, err)
require.Equal(t, 0, len(devices))
}

View File

@ -0,0 +1,21 @@
package anonstore
import (
"context"
"time"
)
type FakeAnonStore struct {
}
func (s *FakeAnonStore) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*Device, error) {
return nil, nil
}
func (s *FakeAnonStore) CreateOrUpdateDevice(ctx context.Context, device *Device) error {
return nil
}
func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}

View File

@ -1,4 +1,4 @@
package clients
package anonimpl
import (
"context"
@ -17,15 +17,6 @@ var _ authn.ContextAwareClient = new(Anonymous)
const timeoutTag = 2 * time.Minute
func ProvideAnonymous(cfg *setting.Cfg, orgService org.Service, anonDeviceService anonymous.Service) *Anonymous {
return &Anonymous{
cfg: cfg,
log: log.New("authn.anonymous"),
orgService: orgService,
anonDeviceService: anonDeviceService,
}
}
type Anonymous struct {
cfg *setting.Cfg
log log.Logger
@ -60,7 +51,7 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.
newCtx, cancel := context.WithTimeout(context.Background(), timeoutTag)
defer cancel()
if err := a.anonDeviceService.TagDevice(newCtx, httpReqCopy, anonymous.AnonDevice); err != nil {
if err := a.anonDeviceService.TagDevice(newCtx, httpReqCopy, anonymous.AnonDeviceUI); err != nil {
a.log.Warn("Failed to tag anonymous session", "error", err)
}
}()

View File

@ -1,4 +1,4 @@
package clients
package anonimpl
import (
"context"

View File

@ -2,144 +2,76 @@ package anonimpl
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"hash/fnv"
"net/http"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/network"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/anonymous"
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
const thirtyDays = 30 * 24 * time.Hour
const deviceIDHeader = "X-Grafana-Device-Id"
type Device struct {
Kind anonymous.DeviceKind `json:"kind"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
LastSeen time.Time `json:"last_seen"`
}
func (a *Device) Key() (string, error) {
key := strings.Builder{}
key.WriteString(a.IP)
key.WriteString(a.UserAgent)
hash := fnv.New128a()
if _, err := hash.Write([]byte(key.String())); err != nil {
return "", fmt.Errorf("failed to write to hash: %w", err)
}
return strings.Join([]string{string(a.Kind), hex.EncodeToString(hash.Sum(nil))}, ":"), nil
}
func (a *Device) UIKey(deviceID string) (string, error) {
return strings.Join([]string{string(a.Kind), deviceID}, ":"), nil
}
const keepFor = time.Hour * 24 * 61
type AnonDeviceService struct {
remoteCache remotecache.CacheStorage
log log.Logger
localCache *localcache.CacheService
log log.Logger
localCache *localcache.CacheService
anonStore anonstore.AnonStore
serverLock *serverlock.ServerLockService
}
func ProvideAnonymousDeviceService(remoteCache remotecache.CacheStorage, usageStats usagestats.Service) *AnonDeviceService {
func ProvideAnonymousDeviceService(usageStats usagestats.Service, authBroker authn.Service,
anonStore anonstore.AnonStore, cfg *setting.Cfg, orgService org.Service,
serverLockService *serverlock.ServerLockService,
) *AnonDeviceService {
a := &AnonDeviceService{
remoteCache: remoteCache,
log: log.New("anonymous-session-service"),
localCache: localcache.New(29*time.Minute, 15*time.Minute),
log: log.New("anonymous-session-service"),
localCache: localcache.New(29*time.Minute, 15*time.Minute),
anonStore: anonStore,
serverLock: serverLockService,
}
usageStats.RegisterMetricsFunc(a.usageStatFn)
anonClient := &Anonymous{
cfg: cfg,
log: log.New("authn.anonymous"),
orgService: orgService,
anonDeviceService: a,
}
if anonClient.cfg.AnonymousEnabled {
authBroker.RegisterClient(anonClient)
authBroker.RegisterPostLoginHook(a.untagDevice, 100)
}
return a
}
func (a *AnonDeviceService) usageStatFn(ctx context.Context) (map[string]any, error) {
anonDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AnonDevice))
// Count the number of unique devices that have been updated in the last 30 days.
// One minute is added to the end time as mysql has a precision of seconds and it will break tests that write too fast.
anonUIDeviceCount, err := a.anonStore.CountDevices(ctx, time.Now().Add(-thirtyDays), time.Now().Add(time.Minute))
if err != nil {
return nil, nil
}
authedDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AuthedDevice))
if err != nil {
return nil, nil
}
anonUIDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AnonDeviceUI))
if err != nil {
return nil, nil
}
authedUIDeviceCount, err := a.remoteCache.Count(ctx, string(anonymous.AuthedDeviceUI))
if err != nil {
return nil, nil
return nil, err
}
return map[string]any{
"stats.anonymous.session.count": anonDeviceCount, // keep session for legacy data
"stats.users.device.count": authedDeviceCount,
"stats.anonymous.device.ui.count": anonUIDeviceCount,
"stats.users.device.ui.count": authedUIDeviceCount,
}, nil
}
func (a *AnonDeviceService) untagDevice(ctx context.Context, device *Device) error {
key, err := device.Key()
if err != nil {
return err
}
if err := a.remoteCache.Delete(ctx, key); err != nil {
return err
}
return nil
}
func (a *AnonDeviceService) untagUIDevice(ctx context.Context, deviceID string, device *Device) error {
key, err := device.UIKey(deviceID)
if err != nil {
return err
}
if err := a.remoteCache.Delete(ctx, key); err != nil {
return err
}
return nil
}
func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Request, device Device) error {
deviceID := httpReq.Header.Get(deviceIDHeader)
if deviceID == "" {
return nil
}
if device.Kind == anonymous.AnonDevice {
device.Kind = anonymous.AnonDeviceUI
} else if device.Kind == anonymous.AuthedDevice {
device.Kind = anonymous.AuthedDeviceUI
}
key, err := device.UIKey(deviceID)
if err != nil {
return err
}
if setting.Env == setting.Dev {
a.log.Debug("Tagging device for UI", "deviceID", deviceID, "device", device, "key", key)
}
func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Request, device *anonstore.Device) error {
key := device.CacheKey()
if _, ok := a.localCache.Get(key); ok {
return nil
@ -147,33 +79,41 @@ func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Reque
a.localCache.SetDefault(key, struct{}{})
deviceJSON, err := json.Marshal(device)
if err != nil {
return err
if setting.Env == setting.Dev {
a.log.Debug("Tagging device for UI", "deviceID", device.DeviceID, "device", device, "key", key)
}
if err := a.remoteCache.Set(ctx, key, deviceJSON, thirtyDays); err != nil {
return err
}
// remove existing tag when device switches to another kind
untagKind := anonymous.AnonDeviceUI
if device.Kind == anonymous.AnonDeviceUI {
untagKind = anonymous.AuthedDeviceUI
}
if err := a.untagUIDevice(ctx, deviceID, &Device{
Kind: untagKind,
IP: device.IP,
UserAgent: device.UserAgent,
}); err != nil {
if err := a.anonStore.CreateOrUpdateDevice(ctx, device); err != nil {
return err
}
return nil
}
func (a *AnonDeviceService) untagDevice(ctx context.Context,
identity *authn.Identity, r *authn.Request, err error) {
if err != nil {
return
}
deviceID := r.HTTPRequest.Header.Get(deviceIDHeader)
if deviceID == "" {
return
}
errD := a.anonStore.DeleteDevice(ctx, deviceID)
if errD != nil {
a.log.Debug("Failed to untag device", "error", err)
}
}
// FIXME: Unexport and remove interface
func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request, kind anonymous.DeviceKind) error {
deviceID := httpReq.Header.Get(deviceIDHeader)
if deviceID == "" {
return nil
}
addr := web.RemoteAddr(httpReq)
ip, err := network.GetIPFromAddress(addr)
if err != nil {
@ -186,54 +126,39 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request
clientIPStr = ""
}
taggedDevice := &Device{
Kind: kind,
IP: clientIPStr,
taggedDevice := &anonstore.Device{
DeviceID: deviceID,
ClientIP: clientIPStr,
UserAgent: httpReq.UserAgent(),
LastSeen: time.Now().UTC(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = a.tagDeviceUI(ctx, httpReq, *taggedDevice)
err = a.tagDeviceUI(ctx, httpReq, taggedDevice)
if err != nil {
a.log.Debug("Failed to tag device for UI", "error", err)
}
key, err := taggedDevice.Key()
if err != nil {
return err
}
if setting.Env == setting.Dev {
a.log.Debug("Tagging device", "device", taggedDevice, "key", key)
}
if _, ok := a.localCache.Get(key); ok {
return nil
}
a.localCache.SetDefault(key, struct{}{})
deviceJSON, err := json.Marshal(taggedDevice)
if err != nil {
return err
}
if err := a.remoteCache.Set(ctx, key, deviceJSON, thirtyDays); err != nil {
return err
}
// remove existing tag when device switches to another kind
untagKind := anonymous.AnonDevice
if kind == anonymous.AnonDevice {
untagKind = anonymous.AuthedDevice
}
if err := a.untagDevice(ctx, &Device{
Kind: untagKind,
IP: taggedDevice.IP,
UserAgent: taggedDevice.UserAgent,
}); err != nil {
return err
}
return nil
}
func (a *AnonDeviceService) Run(ctx context.Context) error {
ticker := time.NewTicker(2 * time.Hour)
for {
select {
case <-ticker.C:
err := a.serverLock.LockAndExecute(ctx, "cleanup old anon devices", time.Hour*10, func(context.Context) {
if err := a.anonStore.DeleteDevicesOlderThan(ctx, time.Now().Add(-keepFor)); err != nil {
a.log.Error("An error occurred while deleting old anon devices", "err", err)
}
})
if err != nil {
a.log.Error("Failed to lock and execute cleanup old anon devices", "error", err)
}
case <-ctx.Done():
return ctx.Err()
}
}
}

View File

@ -2,7 +2,6 @@ package anonimpl
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
@ -10,79 +9,30 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/anonymous"
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/setting"
)
func TestAnonDeviceKey(t *testing.T) {
testCases := []struct {
name string
session *Device
expected string
}{
{
name: "should hash correctly",
session: &Device{
Kind: anonymous.AnonDevice,
IP: "10.10.10.10",
UserAgent: "test",
},
expected: "anon-session:ad9f5c6bf504a9fa77c37a3a6658c0cd",
},
{
name: "should hash correctly with different ip",
session: &Device{
Kind: anonymous.AnonDevice,
IP: "10.10.10.1",
UserAgent: "test",
},
expected: "anon-session:580605320245e8289e0b301074a027c3",
},
{
name: "should hash correctly with different user agent",
session: &Device{
Kind: anonymous.AnonDevice,
IP: "10.10.10.1",
UserAgent: "test2",
},
expected: "anon-session:5fdd04b0bd04a9fa77c4243f8111258b",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.session.Key()
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
// ensure that the key is the same
got, err = tc.session.Key()
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
})
}
}
func TestIntegrationDeviceService_tag(t *testing.T) {
type tagReq struct {
httpReq *http.Request
kind anonymous.DeviceKind
}
testCases := []struct {
name string
req []tagReq
expectedAnonCount int64
expectedAuthedCount int64
expectedAnonUICount int64
expectedAuthedUICount int64
expectedDevice *Device
name string
req []tagReq
expectedAnonUICount int64
expectedKey string
expectedDevice *anonstore.Device
}{
{
name: "no requests",
req: []tagReq{{httpReq: &http.Request{}, kind: anonymous.AnonDevice}},
expectedAnonCount: 0,
expectedAuthedCount: 0,
name: "no requests",
req: []tagReq{{httpReq: &http.Request{}, kind: anonymous.AnonDeviceUI}},
},
{
name: "missing info should not tag",
@ -91,28 +41,8 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
"User-Agent": []string{"test"},
},
},
kind: anonymous.AnonDevice,
kind: anonymous.AnonDeviceUI,
}},
expectedAnonCount: 0,
expectedAuthedCount: 0,
},
{
name: "should tag once",
req: []tagReq{{httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AnonDevice,
},
},
expectedAnonCount: 1,
expectedAuthedCount: 0,
expectedDevice: &Device{
Kind: anonymous.AnonDevice,
IP: "10.30.30.1",
UserAgent: "test"},
},
{
name: "should tag device ID once",
@ -123,16 +53,14 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
},
},
kind: anonymous.AnonDevice,
kind: anonymous.AnonDeviceUI,
},
},
expectedAnonUICount: 1,
expectedAuthedUICount: 0,
expectedAnonCount: 1,
expectedAuthedCount: 0,
expectedDevice: &Device{
Kind: anonymous.AnonDevice,
IP: "10.30.30.1",
expectedAnonUICount: 1,
expectedKey: "ui-anon-session:32mdo31deeqwes",
expectedDevice: &anonstore.Device{
DeviceID: "32mdo31deeqwes",
ClientIP: "10.30.30.1",
UserAgent: "test"},
},
{
@ -144,7 +72,7 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AnonDevice,
kind: anonymous.AnonDeviceUI,
}, {httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
@ -152,61 +80,12 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AnonDevice,
kind: anonymous.AnonDeviceUI,
},
},
expectedAnonCount: 1,
expectedAnonUICount: 1,
expectedAuthedCount: 0,
}, {
name: "authed request should untag anon",
req: []tagReq{{httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AnonDevice,
}, {httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AuthedDevice,
},
},
expectedAnonCount: 0,
expectedAuthedCount: 1,
expectedAuthedUICount: 1,
}, {
name: "anon request should untag authed",
req: []tagReq{{httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AuthedDevice,
}, {httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
"X-Forwarded-For": []string{"10.30.30.1"},
},
},
kind: anonymous.AnonDevice,
},
},
expectedAnonCount: 1,
expectedAnonUICount: 1,
expectedAuthedCount: 0,
},
{
name: "tag 4 different requests - 2 are UI",
name: "tag 2 different requests",
req: []tagReq{{httpReq: &http.Request{
Header: http.Header{
http.CanonicalHeaderKey("User-Agent"): []string{"test"},
@ -214,106 +93,87 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
http.CanonicalHeaderKey(deviceIDHeader): []string{"a"},
},
},
kind: anonymous.AnonDevice,
}, {httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.2"},
},
},
kind: anonymous.AnonDevice,
kind: anonymous.AnonDeviceUI,
}, {httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.3"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"c"},
"X-Forwarded-For": []string{"10.30.30.2"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"b"},
},
},
kind: anonymous.AuthedDevice,
}, {httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.4"},
},
},
kind: anonymous.AuthedDevice,
kind: anonymous.AnonDeviceUI,
},
},
expectedAnonCount: 2,
expectedAuthedCount: 2,
expectedAnonUICount: 1,
expectedAuthedUICount: 1,
expectedAnonUICount: 2,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fakeStore := remotecache.NewFakeStore(t)
anonService := ProvideAnonymousDeviceService(fakeStore, &usagestats.UsageStatsMock{})
store := db.InitTestDB(t)
anonDBStore := anonstore.ProvideAnonDBStore(store)
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
&authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil)
for _, req := range tc.req {
err := anonService.TagDevice(context.Background(), req.httpReq, req.kind)
require.NoError(t, err)
}
devices, err := anonDBStore.ListDevices(context.Background(), nil, nil)
require.NoError(t, err)
require.Len(t, devices, int(tc.expectedAnonUICount))
if tc.expectedDevice != nil {
device := devices[0]
assert.NotZero(t, device.ID)
assert.NotZero(t, device.CreatedAt)
assert.NotZero(t, device.UpdatedAt)
tc.expectedDevice.ID = device.ID
tc.expectedDevice.CreatedAt = device.CreatedAt
tc.expectedDevice.UpdatedAt = device.UpdatedAt
assert.Equal(t, tc.expectedDevice, devices[0])
}
stats, err := anonService.usageStatFn(context.Background())
require.NoError(t, err)
assert.Equal(t, tc.expectedAnonCount, stats["stats.anonymous.session.count"].(int64))
assert.Equal(t, tc.expectedAuthedCount, stats["stats.users.device.count"].(int64))
assert.Equal(t, tc.expectedAnonUICount, stats["stats.anonymous.device.ui.count"].(int64))
assert.Equal(t, tc.expectedAuthedUICount, stats["stats.users.device.ui.count"].(int64))
if tc.expectedDevice != nil {
key, err := tc.expectedDevice.Key()
require.NoError(t, err)
k, err := fakeStore.Get(context.Background(), key)
require.NoError(t, err)
gotDevice := &Device{}
err = json.Unmarshal(k, gotDevice)
require.NoError(t, err)
assert.NotNil(t, gotDevice.LastSeen)
gotDevice.LastSeen = time.Time{}
assert.Equal(t, tc.expectedDevice, gotDevice)
}
assert.Equal(t, tc.expectedAnonUICount, stats["stats.anonymous.device.ui.count"].(int64), stats)
})
}
}
// Ensure that the local cache prevents request from being tagged
func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
fakeStore := remotecache.NewFakeStore(t)
anonService := ProvideAnonymousDeviceService(fakeStore, &usagestats.UsageStatsMock{})
store := db.InitTestDB(t)
anonDBStore := anonstore.ProvideAnonDBStore(store)
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
&authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil)
req := &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.2"},
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.2"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"32mdo31deeqwes"},
},
}
anonDevice := &Device{
Kind: anonymous.AnonDevice,
IP: "10.30.30.2",
anonDevice := &anonstore.Device{
DeviceID: "32mdo31deeqwes",
ClientIP: "10.30.30.2",
UserAgent: "test",
LastSeen: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
key, err := anonDevice.Key()
require.NoError(t, err)
key := anonDevice.CacheKey()
anonService.localCache.SetDefault(key, true)
err = anonService.TagDevice(context.Background(), req, anonymous.AnonDevice)
err := anonService.TagDevice(context.Background(), req, anonymous.AnonDeviceUI)
require.NoError(t, err)
stats, err := anonService.usageStatFn(context.Background())
require.NoError(t, err)
assert.Equal(t, int64(0), stats["stats.anonymous.session.count"].(int64))
assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64))
}

View File

@ -8,10 +8,7 @@ import (
type DeviceKind string
const (
AnonDevice DeviceKind = "anon-session"
AuthedDevice DeviceKind = "authed-session"
AnonDeviceUI DeviceKind = "ui-anon-session"
AuthedDeviceUI DeviceKind = "ui-authed-session"
AnonDeviceUI DeviceKind = "ui-anon-session"
)
type Service interface {

View File

@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/anonymous"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
@ -64,7 +63,6 @@ func ProvideService(
apikeyService apikey.Service, userService user.Service,
jwtService auth.JWTVerifierService,
usageStats usagestats.Service,
anonDeviceService anonymous.Service,
userProtectionService login.UserProtectionService,
loginAttempts loginattempt.Service, quotaService quota.Service,
authInfoService login.AuthInfoService, renderService rendering.Service,
@ -91,11 +89,7 @@ func ProvideService(
s.RegisterClient(clients.ProvideAPIKey(apikeyService, userService))
if cfg.LoginCookieName != "" {
s.RegisterClient(clients.ProvideSession(cfg, sessionService, features, anonDeviceService))
}
if s.cfg.AnonymousEnabled {
s.RegisterClient(clients.ProvideAnonymous(cfg, orgService, anonDeviceService))
s.RegisterClient(clients.ProvideSession(cfg, sessionService, features))
}
var proxyClients []authn.ProxyClient

View File

@ -3,13 +3,11 @@ package clients
import (
"context"
"errors"
"net/http"
"net/url"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/network"
"github.com/grafana/grafana/pkg/services/anonymous"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -21,24 +19,20 @@ var _ authn.HookClient = new(Session)
var _ authn.ContextAwareClient = new(Session)
func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService,
features *featuremgmt.FeatureManager, anonDeviceService anonymous.Service) *Session {
features *featuremgmt.FeatureManager) *Session {
return &Session{
cfg: cfg,
features: features,
sessionService: sessionService,
log: log.New(authn.ClientSession),
anonDeviceService: anonDeviceService,
tagDevices: cfg.TagAuthedDevices,
cfg: cfg,
features: features,
sessionService: sessionService,
log: log.New(authn.ClientSession),
}
}
type Session struct {
cfg *setting.Cfg
features *featuremgmt.FeatureManager
sessionService auth.UserTokenService
log log.Logger
tagDevices bool
anonDeviceService anonymous.Service
cfg *setting.Cfg
features *featuremgmt.FeatureManager
sessionService auth.UserTokenService
log log.Logger
}
func (s *Session) Name() string {
@ -67,29 +61,6 @@ func (s *Session) Authenticate(ctx context.Context, r *authn.Request) (*authn.Id
}
}
if s.tagDevices {
// Tag authed devices
httpReqCopy := &http.Request{}
if r.HTTPRequest != nil && r.HTTPRequest.Header != nil {
// avoid r.HTTPRequest.Clone(context.Background()) as we do not require a full clone
httpReqCopy.Header = r.HTTPRequest.Header.Clone()
httpReqCopy.RemoteAddr = r.HTTPRequest.RemoteAddr
}
go func() {
defer func() {
if err := recover(); err != nil {
s.log.Warn("Tag anon session panic", "err", err)
}
}()
newCtx, cancel := context.WithTimeout(context.Background(), timeoutTag)
defer cancel()
if err := s.anonDeviceService.TagDevice(newCtx, httpReqCopy, anonymous.AuthedDevice); err != nil {
s.log.Warn("Failed to tag anonymous session", "error", err)
}
}()
}
return &authn.Identity{
ID: authn.NamespacedID(authn.NamespaceUser, token.UserId),
SessionToken: token,

View File

@ -11,7 +11,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/anonymous/anontest"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
@ -30,7 +29,7 @@ func TestSession_Test(t *testing.T) {
cfg := setting.NewCfg()
cfg.LoginCookieName = ""
cfg.LoginMaxLifetime = 20 * time.Second
s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, featuremgmt.WithFeatures(), &anontest.FakeAnonymousSessionService{})
s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, featuremgmt.WithFeatures())
disabled := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq})
assert.False(t, disabled)
@ -146,7 +145,7 @@ func TestSession_Authenticate(t *testing.T) {
cfg.LoginCookieName = cookieName
cfg.TokenRotationIntervalMinutes = 10
cfg.LoginMaxLifetime = 20 * time.Second
s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.features, &anontest.FakeAnonymousSessionService{})
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)
@ -186,7 +185,7 @@ func TestSession_Hook(t *testing.T) {
token.UnhashedToken = "new-token"
return true, token, nil
},
}, featuremgmt.WithFeatures(), &anontest.FakeAnonymousSessionService{})
}, featuremgmt.WithFeatures())
sampleID := &authn.Identity{
SessionToken: &auth.UserToken{
@ -220,7 +219,7 @@ func TestSession_Hook(t *testing.T) {
})
t.Run("should not rotate token with feature flag", func(t *testing.T) {
s := ProvideSession(setting.NewCfg(), nil, featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation), &anontest.FakeAnonymousSessionService{})
s := ProvideSession(setting.NewCfg(), nil, featuremgmt.WithFeatures(featuremgmt.FlagClientTokenRotation))
req := &authn.Request{}
identity := &authn.Identity{}

View File

@ -0,0 +1,25 @@
package anonservice
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func AddMigration(mg *migrator.Migrator) {
var anonV1 = migrator.Table{
Name: "anon_device",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "client_ip", Type: migrator.DB_NVarchar, Length: 255, Nullable: false},
{Name: "created_at", Type: migrator.DB_DateTime, Nullable: false},
{Name: "device_id", Type: migrator.DB_NVarchar, Length: 127, Nullable: false},
{Name: "updated_at", Type: migrator.DB_DateTime, Nullable: false},
{Name: "user_agent", Type: migrator.DB_NVarchar, Length: 255, Nullable: false},
},
Indices: []*migrator.Index{
{Cols: []string{"device_id"}, Type: migrator.UniqueIndex},
{Cols: []string{"updated_at"}, Type: migrator.IndexType},
},
}
mg.AddMigration("create anon_device table", migrator.NewAddTableMigration(anonV1))
mg.AddMigration("add unique index anon_device.device_id", migrator.NewAddIndexMigration(anonV1, anonV1.Indices[0]))
mg.AddMigration("add index anon_device.updated_at", migrator.NewAddIndexMigration(anonV1, anonV1.Indices[1]))
}

View File

@ -3,6 +3,7 @@ package migrations
import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/oauthserver"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
@ -96,6 +97,8 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
oauthserver.AddMigration(mg)
}
}
anonservice.AddMigration(mg)
}
func addStarMigrations(mg *Migrator) {

View File

@ -287,8 +287,6 @@ type Cfg struct {
// Not documented & not supported
// stand in until a more complete solution is implemented
AuthConfigUIAdminAccess bool
// TO REMOVE: Not documented & not supported. Remove in 10.3
TagAuthedDevices bool
// AWS Plugin Auth
AWSAllowedAuthProviders []string
@ -1551,7 +1549,6 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
// Do not use
cfg.AuthConfigUIAdminAccess = auth.Key("config_ui_admin_access").MustBool(false)
cfg.TagAuthedDevices = auth.Key("tag_authed_devices").MustBool(true)
cfg.DisableLoginForm = auth.Key("disable_login_form").MustBool(false)
DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)