diff --git a/pkg/components/apikeygenprefixed/apikeygen.go b/pkg/components/apikeygenprefixed/apikeygen.go new file mode 100644 index 00000000000..3d504200b82 --- /dev/null +++ b/pkg/components/apikeygenprefixed/apikeygen.go @@ -0,0 +1,93 @@ +package apikeygenprefix + +import ( + "encoding/hex" + "hash/crc32" + "strings" + + "github.com/grafana/grafana/pkg/util" +) + +const GrafanaPrefix = "gl" + +type KeyGenResult struct { + HashedKey string + ClientSecret string +} + +type PrefixedKey struct { + ServiceID string + Secret string + Checksum string +} + +func (p *PrefixedKey) Hash() (string, error) { + hash, err := util.EncodePassword(p.Secret, p.Checksum) + if err != nil { + return "", err + } + return hash, nil +} + +func (p *PrefixedKey) key() string { + return GrafanaPrefix + p.ServiceID + "_" + p.Secret +} + +func (p *PrefixedKey) CalculateChecksum() string { + checksum := crc32.ChecksumIEEE([]byte(p.key())) + //checksum to []byte + checksumBytes := make([]byte, 4) + checksumBytes[0] = byte(checksum) + checksumBytes[1] = byte(checksum >> 8) + checksumBytes[2] = byte(checksum >> 16) + checksumBytes[3] = byte(checksum >> 24) + + return hex.EncodeToString(checksumBytes) +} + +func (p *PrefixedKey) String() string { + return p.key() + "_" + p.Checksum +} + +func New(serviceID string) (KeyGenResult, error) { + result := KeyGenResult{} + + secret, err := util.GetRandomString(32) + if err != nil { + return result, err + } + + key := PrefixedKey{ServiceID: serviceID, Secret: secret, Checksum: ""} + key.Checksum = key.CalculateChecksum() + + result.HashedKey, err = key.Hash() + if err != nil { + return result, err + } + + result.ClientSecret = key.String() + + return result, nil +} + +func Decode(keyString string) (*PrefixedKey, error) { + if !strings.HasPrefix(keyString, GrafanaPrefix) { + return nil, &ErrInvalidApiKey{} + } + + parts := strings.Split(keyString, "_") + if len(parts) != 3 { + return nil, &ErrInvalidApiKey{} + } + + key := &PrefixedKey{ + ServiceID: strings.TrimPrefix(parts[0], GrafanaPrefix), + Secret: parts[1], + Checksum: parts[2], + } + if key.CalculateChecksum() != key.Checksum { + return nil, &ErrInvalidApiKey{} + } + + return key, nil +} diff --git a/pkg/components/apikeygenprefixed/apikeygen_test.go b/pkg/components/apikeygenprefixed/apikeygen_test.go new file mode 100644 index 00000000000..c1669010d92 --- /dev/null +++ b/pkg/components/apikeygenprefixed/apikeygen_test.go @@ -0,0 +1,40 @@ +package apikeygenprefix + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApiKeyValidation(t *testing.T) { + result := KeyGenResult{ + ClientSecret: "glsa_yscW25imSKJIuav8zF37RZmnbiDvB05G_fcaaf58a", + HashedKey: "26cd2524985150529dc5f32109f544860512b999766e11bc8f3d5711bf0ba6e7020099f9f21538b5df94d577782f7431dd27", + } + + keyInfo, err := Decode(result.ClientSecret) + require.NoError(t, err) + require.Equal(t, "sa", keyInfo.ServiceID) + require.Equal(t, "yscW25imSKJIuav8zF37RZmnbiDvB05G", keyInfo.Secret) + require.Equal(t, "fcaaf58a", keyInfo.Checksum) + + hash, err := keyInfo.Hash() + require.NoError(t, err) + require.Equal(t, result.HashedKey, hash) +} + +func TestApiKeyGen(t *testing.T) { + result, err := New("sa") + require.NoError(t, err) + + assert.NotEmpty(t, result.ClientSecret) + assert.NotEmpty(t, result.HashedKey) + + keyInfo, err := Decode(result.ClientSecret) + require.NoError(t, err) + + hash, err := keyInfo.Hash() + require.NoError(t, err) + require.Equal(t, result.HashedKey, hash) +} diff --git a/pkg/components/apikeygenprefixed/cmd/main.go b/pkg/components/apikeygenprefixed/cmd/main.go new file mode 100644 index 00000000000..750783ef910 --- /dev/null +++ b/pkg/components/apikeygenprefixed/cmd/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + "strconv" + + apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" +) + +// placeholder key generator +func main() { + // get number of keys to generate from args + numKeys := 1 + if len(os.Args) > 1 { + var err error + numKeys, err = strconv.Atoi(os.Args[1]) + if err != nil { + fmt.Println("ERROR: invalid number of keys to generate:", err) + return + } + } + + for i := 0; i < numKeys; i++ { + key, err := apikeygenprefix.New("pl") + if err != nil { + fmt.Println("ERROR: generating key failed:", err) + return + } + + fmt.Printf("\nGenerated key: %d:\n", i+1) + fmt.Println(key.ClientSecret) + fmt.Printf("\nGenerated key hash: %d \n", i+1) + fmt.Println(key.HashedKey) + } +} diff --git a/pkg/components/apikeygenprefixed/errors.go b/pkg/components/apikeygenprefixed/errors.go new file mode 100644 index 00000000000..7d8212ce7ea --- /dev/null +++ b/pkg/components/apikeygenprefixed/errors.go @@ -0,0 +1,14 @@ +package apikeygenprefix + +import "github.com/grafana/grafana/pkg/components/apikeygen" + +type ErrInvalidApiKey struct { +} + +func (e *ErrInvalidApiKey) Error() string { + return "invalid API key" +} + +func (e *ErrInvalidApiKey) Unwrap() error { + return apikeygen.ErrInvalidApiKey +} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 263bb20e0d4..4e09458bd5b 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -11,6 +11,7 @@ import ( "time" "github.com/grafana/grafana/pkg/components/apikeygen" + apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/infra/remotecache" @@ -186,6 +187,45 @@ func (h *ContextHandler) initContextWithAnonymousUser(reqContext *models.ReqCont return true } +func (h *ContextHandler) getPrefixedAPIKey(ctx context.Context, keyString string) (*models.ApiKey, error) { + // prefixed decode key + decoded, err := apikeygenprefix.Decode(keyString) + if err != nil { + return nil, err + } + + hash, err := decoded.Hash() + if err != nil { + return nil, err + } + + return h.SQLStore.GetAPIKeyByHash(ctx, hash) +} + +func (h *ContextHandler) getAPIKey(ctx context.Context, keyString string) (*models.ApiKey, error) { + decoded, err := apikeygen.Decode(keyString) + if err != nil { + return nil, err + } + + // fetch key + keyQuery := models.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} + if err := h.SQLStore.GetApiKeyByName(ctx, &keyQuery); err != nil { + return nil, err + } + + // validate api key + isValid, err := apikeygen.IsValid(decoded, keyQuery.Result.Key) + if err != nil { + return nil, err + } + if !isValid { + return nil, apikeygen.ErrInvalidApiKey + } + + return keyQuery.Result, nil +} + func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bool { header := reqContext.Req.Header.Get("Authorization") parts := strings.SplitN(header, " ", 2) @@ -206,30 +246,22 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo _, span := h.tracer.Start(reqContext.Req.Context(), "initContextWithAPIKey") defer span.End() - // base64 decode key - decoded, err := apikeygen.Decode(keyString) - if err != nil { - reqContext.JsonApiErr(401, InvalidAPIKey, err) - return true + var ( + apikey *models.ApiKey + errKey error + ) + if strings.HasPrefix(keyString, apikeygenprefix.GrafanaPrefix) { + apikey, errKey = h.getPrefixedAPIKey(reqContext.Req.Context(), keyString) // decode prefixed key + } else { + apikey, errKey = h.getAPIKey(reqContext.Req.Context(), keyString) // decode legacy api key } - // fetch key - keyQuery := models.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} - if err := h.SQLStore.GetApiKeyByName(reqContext.Req.Context(), &keyQuery); err != nil { - reqContext.JsonApiErr(401, InvalidAPIKey, err) - return true - } - - apikey := keyQuery.Result - - // validate api key - isValid, err := apikeygen.IsValid(decoded, apikey.Key) - if err != nil { - reqContext.JsonApiErr(500, "Validating API key failed", err) - return true - } - if !isValid { - reqContext.JsonApiErr(401, InvalidAPIKey, err) + if errKey != nil { + status := http.StatusInternalServerError + if errors.Is(errKey, apikeygen.ErrInvalidApiKey) { + status = http.StatusUnauthorized + } + reqContext.JsonApiErr(status, InvalidAPIKey, errKey) return true } @@ -239,7 +271,7 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo getTime = time.Now } if apikey.Expires != nil && *apikey.Expires <= getTime().Unix() { - reqContext.JsonApiErr(401, "Expired API key", err) + reqContext.JsonApiErr(http.StatusUnauthorized, "Expired API key", nil) return true } diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index dec1d498280..ecce0763a31 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -8,13 +8,16 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/components/apikeygen" + apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/web" ) -const failedToDeleteMsg = "Failed to delete API key" +const ( + failedToDeleteMsg = "Failed to delete API key" + ServiceID = "sa" +) type TokenDTO struct { Id int64 `json:"id"` @@ -106,7 +109,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon } } - newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name) + newKeyInfo, err := apikeygenprefix.New(ServiceID) if err != nil { return response.Error(http.StatusInternalServerError, "Generating API key failed", err) } diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 7f04cc8d1bb..c7b9d74cdb5 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/apikeygen" + apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" @@ -148,6 +149,14 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { assert.Equal(t, sa.Id, *query.Result.ServiceAccountId) assert.Equal(t, sa.OrgId, query.Result.OrgId) + assert.True(t, strings.HasPrefix(actualBody["key"].(string), "glsa")) + + keyInfo, err := apikeygenprefix.Decode(actualBody["key"].(string)) + assert.NoError(t, err) + + hash, err := keyInfo.Hash() + require.NoError(t, err) + require.Equal(t, query.Result.Key, hash) } }) } diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index de92da3940f..74d75356641 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -2,6 +2,7 @@ package sqlstore import ( "context" + "fmt" "time" "xorm.io/xorm" @@ -144,3 +145,19 @@ func (ss *SQLStore) GetApiKeyByName(ctx context.Context, query *models.GetApiKey return nil }) } + +func (ss *SQLStore) GetAPIKeyByHash(ctx context.Context, hash string) (*models.ApiKey, error) { + var apikey models.ApiKey + err := ss.WithDbSession(ctx, func(sess *DBSession) error { + has, err := sess.Table("api_key").Where(fmt.Sprintf("%s = ?", dialect.Quote("key")), hash).Get(&apikey) + if err != nil { + return err + } else if !has { + return models.ErrInvalidApiKey + } + + return nil + }) + + return &apikey, err +} diff --git a/pkg/services/sqlstore/apikey_test.go b/pkg/services/sqlstore/apikey_test.go index 9a8f0ae7421..c1833cc907f 100644 --- a/pkg/services/sqlstore/apikey_test.go +++ b/pkg/services/sqlstore/apikey_test.go @@ -35,6 +35,13 @@ func TestApiKeyDataAccess(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, query.Result) }) + + t.Run("Should be able to get key by hash", func(t *testing.T) { + key, err := ss.GetAPIKeyByHash(context.Background(), cmd.Key) + + assert.Nil(t, err) + assert.NotNil(t, key) + }) }) t.Run("Add non expiring key", func(t *testing.T) { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 54dfb8de5db..877d54f6830 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -635,3 +635,7 @@ func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query func (m *SQLStoreMock) IsAdminOfTeams(ctx context.Context, query *models.IsAdminOfTeamsQuery) error { return m.ExpectedError } + +func (m *SQLStoreMock) GetAPIKeyByHash(ctx context.Context, hash string) (*models.ApiKey, error) { + return nil, m.ExpectedError +} diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 64d5700c30f..03b0e31f465 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -128,6 +128,7 @@ type Store interface { AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand) error GetApiKeyById(ctx context.Context, query *models.GetApiKeyByIdQuery) error GetApiKeyByName(ctx context.Context, query *models.GetApiKeyByNameQuery) error + GetAPIKeyByHash(ctx context.Context, hash string) (*models.ApiKey, error) UpdateTempUserStatus(ctx context.Context, cmd *models.UpdateTempUserStatusCommand) error CreateTempUser(ctx context.Context, cmd *models.CreateTempUserCommand) error UpdateTempUserWithEmailSent(ctx context.Context, cmd *models.UpdateTempUserWithEmailSentCommand) error