diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml
new file mode 100644
index 00000000000..1a0f38b4a96
--- /dev/null
+++ b/pkg/db/fixtures/api_tokens.yml
@@ -0,0 +1,33 @@
+- id: 1
+ title: 'test token 1'
+ token_salt: iC1Qbpf7H1
+ token_hash: a1813a558185d99f5197d2d549e4dd91292376aa00210229d70f77b57e165f6613fd12c1f790aa6493548cb9bceff33b45b4
+ token_last_eight: 75f29d2e
+ permissions: '{"tasks":["read_all","update"]}'
+ expires_at: 2099-01-01 00:00:00
+ owner_id: 1
+ created: 2023-09-01 07:00:00
+ updated: 2023-09-01 07:00:00
+ # token in plaintext is tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e
+- id: 2
+ title: 'test token 2'
+ token_salt: EtwMsqDfOA
+ token_hash: 5c4d80c58947f21295064d473937709f1159ab09085eb59e38783da6032181069ec2e1d236486533b66999f9f4ac375b45f5
+ token_last_eight: 235008c8
+ permissions: '{"tasks":["read_all","update"]}'
+ expires_at: 2023-01-01 00:00:00
+ owner_id: 1
+ created: 2023-09-01 07:00:00
+ updated: 2023-09-01 07:00:00
+ # token in plaintext is tk_a5e6f92ddbad68f49ee2c63e52174db0235008c8
+- id: 3
+ title: 'test token 3'
+ token_salt: AHeetyp1aB
+ token_hash: da4b9c3aa72633274c37ab3419fbfbe4c5b79310b76027ac36f85e4c5ad0c2342a1d9e1c9b72ca07ec0a66ad2ee3505539af
+ token_last_eight: 0b8dcb7c
+ permissions: '{"tasks":["read_all","update"]}'
+ expires_at: 2099-01-01 00:00:00
+ owner_id: 2
+ created: 2023-09-01 07:00:00
+ updated: 2023-09-01 07:00:00
+ # token in plaintext is tk_5e29ae2ae079781ff73b0a3e0fe4d75a0b8dcb7c
diff --git a/pkg/integrations/api_tokens_test.go b/pkg/integrations/api_tokens_test.go
new file mode 100644
index 00000000000..4ccde721f59
--- /dev/null
+++ b/pkg/integrations/api_tokens_test.go
@@ -0,0 +1,113 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public Licensee as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package integrations
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/routes"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/labstack/echo/v4"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIToken(t *testing.T) {
+ t.Run("valid token", func(t *testing.T) {
+ e, err := setupTestEnv()
+ assert.NoError(t, err)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
+ res := httptest.NewRecorder()
+ c := e.NewContext(req, res)
+ h := routes.SetupTokenMiddleware()(func(c echo.Context) error {
+ u, err := auth.GetAuthFromClaims(c)
+ if err != nil {
+ return c.String(http.StatusInternalServerError, err.Error())
+ }
+
+ return c.JSON(http.StatusOK, u)
+ })
+
+ req.Header.Set(echo.HeaderAuthorization, "Bearer tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e") // Token 1
+ assert.NoError(t, h(c))
+ // check if the request handlers "see" the request as if it came directly from that user
+ assert.Contains(t, res.Body.String(), `"username":"user1"`)
+ })
+ t.Run("invalid token", func(t *testing.T) {
+ e, err := setupTestEnv()
+ assert.NoError(t, err)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
+ res := httptest.NewRecorder()
+ c := e.NewContext(req, res)
+ h := routes.SetupTokenMiddleware()(func(c echo.Context) error {
+ return c.String(http.StatusOK, "test")
+ })
+
+ req.Header.Set(echo.HeaderAuthorization, "Bearer tk_loremipsumdolorsitamet")
+ assert.Error(t, h(c))
+ })
+ t.Run("expired token", func(t *testing.T) {
+ e, err := setupTestEnv()
+ assert.NoError(t, err)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
+ res := httptest.NewRecorder()
+ c := e.NewContext(req, res)
+ h := routes.SetupTokenMiddleware()(func(c echo.Context) error {
+ return c.String(http.StatusOK, "test")
+ })
+
+ req.Header.Set(echo.HeaderAuthorization, "Bearer tk_a5e6f92ddbad68f49ee2c63e52174db0235008c8") // Token 2
+ assert.Error(t, h(c))
+ })
+ t.Run("valid token, invalid scope", func(t *testing.T) {
+ e, err := setupTestEnv()
+ assert.NoError(t, err)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
+ res := httptest.NewRecorder()
+ c := e.NewContext(req, res)
+ h := routes.SetupTokenMiddleware()(func(c echo.Context) error {
+ return c.String(http.StatusOK, "test")
+ })
+
+ req.Header.Set(echo.HeaderAuthorization, "Bearer tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e")
+ assert.Error(t, h(c))
+ })
+ t.Run("jwt", func(t *testing.T) {
+ e, err := setupTestEnv()
+ assert.NoError(t, err)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
+ res := httptest.NewRecorder()
+ c := e.NewContext(req, res)
+ h := routes.SetupTokenMiddleware()(func(c echo.Context) error {
+ return c.String(http.StatusOK, "test")
+ })
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 1)
+ assert.NoError(t, err)
+ jwt, err := auth.NewUserJWTAuthtoken(u, false)
+ assert.NoError(t, err)
+
+ req.Header.Set(echo.HeaderAuthorization, "Bearer "+jwt)
+ assert.NoError(t, h(c))
+ })
+}
diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go
index 560564d7c9e..4336a8b8fa7 100644
--- a/pkg/models/api_routes.go
+++ b/pkg/models/api_routes.go
@@ -129,7 +129,8 @@ func GetAvailableAPIRoutesForToken(c echo.Context) error {
// CanDoAPIRoute checks if a token is allowed to use the current api route
func CanDoAPIRoute(c echo.Context, token *APIToken) (can bool) {
- routeGroupName := getRouteGroupName(c.Path())
+ path := c.Request().URL.Path
+ routeGroupName := getRouteGroupName(path)
group, hasGroup := token.Permissions[routeGroupName]
if !hasGroup {
@@ -142,19 +143,19 @@ func CanDoAPIRoute(c echo.Context, token *APIToken) (can bool) {
return false
}
- if routes.Create != nil && routes.Create.Path == c.Path() && routes.Create.Method == c.Request().Method {
+ if routes.Create != nil && routes.Create.Path == path && routes.Create.Method == c.Request().Method {
route = "create"
}
- if routes.ReadOne != nil && routes.ReadOne.Path == c.Path() && routes.ReadOne.Method == c.Request().Method {
+ if routes.ReadOne != nil && routes.ReadOne.Path == path && routes.ReadOne.Method == c.Request().Method {
route = "read_one"
}
- if routes.ReadAll != nil && routes.ReadAll.Path == c.Path() && routes.ReadAll.Method == c.Request().Method {
+ if routes.ReadAll != nil && routes.ReadAll.Path == path && routes.ReadAll.Method == c.Request().Method {
route = "read_all"
}
- if routes.Update != nil && routes.Update.Path == c.Path() && routes.Update.Method == c.Request().Method {
+ if routes.Update != nil && routes.Update.Path == path && routes.Update.Method == c.Request().Method {
route = "update"
}
- if routes.Delete != nil && routes.Delete.Path == c.Path() && routes.Delete.Method == c.Request().Method {
+ if routes.Delete != nil && routes.Delete.Path == path && routes.Delete.Method == c.Request().Method {
route = "delete"
}
diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go
index f31ea4ff508..b1a4c98efa6 100644
--- a/pkg/models/api_tokens.go
+++ b/pkg/models/api_tokens.go
@@ -21,6 +21,7 @@ import (
"crypto/subtle"
"encoding/hex"
"time"
+ "xorm.io/builder"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/utils"
@@ -132,19 +133,24 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
tokens := []*APIToken{}
- query := s.Where("owner_id = ?", a.GetID()).
- Limit(getLimitFromPageIndex(page, perPage))
+ var where builder.Cond = builder.Eq{"owner_id": a.GetID()}
if search != "" {
- query = query.Where(db.ILIKE("title", search))
+ where = builder.And(
+ where,
+ db.ILIKE("title", search),
+ )
}
- err = query.Find(&tokens)
+ err = s.
+ Where(where).
+ Limit(getLimitFromPageIndex(page, perPage)).
+ Find(&tokens)
if err != nil {
return nil, 0, 0, err
}
- totalCount, err := query.Count(&APIToken{})
+ totalCount, err := s.Where(where).Count(&APIToken{})
return tokens, len(tokens), totalCount, err
}
diff --git a/pkg/models/api_tokens_test.go b/pkg/models/api_tokens_test.go
new file mode 100644
index 00000000000..9b7bc10ae2f
--- /dev/null
+++ b/pkg/models/api_tokens_test.go
@@ -0,0 +1,117 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public Licensee as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package models
+
+import (
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIToken_ReadAll(t *testing.T) {
+ u := &user.User{ID: 1}
+ token := &APIToken{}
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ // Checking if the user only sees their own tokens
+
+ result, count, total, err := token.ReadAll(s, u, "", 1, 50)
+ assert.NoError(t, err)
+ tokens, is := result.([]*APIToken)
+ assert.Truef(t, is, "tokens are not of type []*APIToken")
+ assert.Len(t, tokens, 2)
+ assert.Equal(t, count, len(tokens))
+ assert.Equal(t, int64(2), total)
+ assert.Equal(t, int64(1), tokens[0].ID)
+ assert.Equal(t, int64(2), tokens[1].ID)
+}
+
+func TestAPIToken_CanDelete(t *testing.T) {
+ t.Run("own token", func(t *testing.T) {
+ u := &user.User{ID: 1}
+ token := &APIToken{ID: 1}
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ can, err := token.CanDelete(s, u)
+ assert.NoError(t, err)
+ assert.True(t, can)
+ })
+ t.Run("noneixsting token", func(t *testing.T) {
+ u := &user.User{ID: 1}
+ token := &APIToken{ID: 999}
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ can, err := token.CanDelete(s, u)
+ assert.NoError(t, err)
+ assert.False(t, can)
+ })
+ t.Run("token of another user", func(t *testing.T) {
+ u := &user.User{ID: 2}
+ token := &APIToken{ID: 1}
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ can, err := token.CanDelete(s, u)
+ assert.NoError(t, err)
+ assert.False(t, can)
+ })
+}
+
+func TestAPIToken_Create(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ u := &user.User{ID: 1}
+ token := &APIToken{}
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ err := token.Create(s, u)
+ })
+}
+
+func TestAPIToken_GetTokenFromTokenString(t *testing.T) {
+ t.Run("valid token", func(t *testing.T) {
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ token, err := GetTokenFromTokenString(s, "tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e") // Token 1
+
+ assert.NoError(t, err)
+ assert.Equal(t, int64(1), token.ID)
+ })
+ t.Run("invalid token", func(t *testing.T) {
+ s := db.NewSession()
+ defer s.Close()
+ db.LoadAndAssertFixtures(t)
+
+ _, err := GetTokenFromTokenString(s, "tk_loremipsum")
+
+ assert.Error(t, err)
+ assert.True(t, IsErrAPITokenInvalid(err))
+ })
+}
diff --git a/pkg/models/models.go b/pkg/models/models.go
index 4e212bdee47..ec2e92f7c91 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -58,6 +58,7 @@ func GetTables() []interface{} {
&SavedFilter{},
&Subscription{},
&Favorite{},
+ &APIToken{},
}
}
diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go
index fb383ed91b9..6dd66990b34 100644
--- a/pkg/models/unit_tests.go
+++ b/pkg/models/unit_tests.go
@@ -64,6 +64,7 @@ func SetupTests() {
"saved_filters",
"subscriptions",
"favorites",
+ "api_tokens",
)
if err != nil {
log.Fatal(err)