diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml new file mode 100644 index 00000000000..fae07325698 --- /dev/null +++ b/pkg/db/fixtures/api_tokens.yml @@ -0,0 +1,30 @@ +- 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 + # 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 + # 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 + # 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/migration/20230831155832.go b/pkg/migration/20230831155832.go new file mode 100644 index 00000000000..19157c7674a --- /dev/null +++ b/pkg/migration/20230831155832.go @@ -0,0 +1,53 @@ +// 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 migration + +import ( + "time" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type apiTokens20230831155832 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"` + Title string `xorm:"not null" json:"title"` + TokenSalt string `xorm:"not null" json:"-"` + TokenHash string `xorm:"not null unique" json:"-"` + TokenLastEight string `xorm:"not null index varchar(8)" json:"-"` + Permissions map[string][]string `xorm:"json not null" json:"permissions"` + ExpiresAt time.Time `xorm:"not null" json:"expires_at"` + OwnerID int64 `xorm:"bigint not null" json:"-"` + Created time.Time `xorm:"created not null" json:"created"` +} + +func (apiTokens20230831155832) TableName() string { + return "api_tokens" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20230831155832", + Description: "", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(apiTokens20230831155832{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go new file mode 100644 index 00000000000..c7655a06382 --- /dev/null +++ b/pkg/models/api_routes.go @@ -0,0 +1,216 @@ +// 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 ( + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +var apiTokenRoutes = map[string]*APITokenRoute{} + +func init() { + apiTokenRoutes = make(map[string]*APITokenRoute) +} + +type APITokenRoute struct { + Create *RouteDetail `json:"create,omitempty"` + ReadOne *RouteDetail `json:"read_one,omitempty"` + ReadAll *RouteDetail `json:"read_all,omitempty"` + Update *RouteDetail `json:"update,omitempty"` + Delete *RouteDetail `json:"delete,omitempty"` +} + +type RouteDetail struct { + Path string `json:"path"` + Method string `json:"method"` +} + +func getRouteGroupName(path string) string { + parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/") + filteredParts := []string{} + for _, part := range parts { + if strings.HasPrefix(part, ":") { + continue + } + + filteredParts = append(filteredParts, part) + } + + finalName := strings.Join(filteredParts, "_") + switch finalName { + case "tasks_all": + return "tasks" + default: + return finalName + } +} + +// CollectRoutesForAPITokenUsage gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens. +func CollectRoutesForAPITokenUsage(route echo.Route) { + + if !strings.Contains(route.Name, "(*WebHandler)") { + return + } + + routeGroupName := getRouteGroupName(route.Path) + + if routeGroupName == "subscriptions" || + routeGroupName == "notifications" || + routeGroupName == "tokens" || + strings.HasSuffix(routeGroupName, "_bulk") { + return + } + + _, has := apiTokenRoutes[routeGroupName] + if !has { + apiTokenRoutes[routeGroupName] = &APITokenRoute{} + } + + if strings.Contains(route.Name, "CreateWeb") { + apiTokenRoutes[routeGroupName].Create = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } + } + if strings.Contains(route.Name, "ReadOneWeb") { + apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } + } + if strings.Contains(route.Name, "ReadAllWeb") { + apiTokenRoutes[routeGroupName].ReadAll = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } + } + if strings.Contains(route.Name, "UpdateWeb") { + apiTokenRoutes[routeGroupName].Update = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } + } + if strings.Contains(route.Name, "DeleteWeb") { + apiTokenRoutes[routeGroupName].Delete = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } + } +} + +// GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage. +// @Summary Get a list of all token api routes +// @Description Returns a list of all API routes which are available to use with an api token, not a user login. +// @tags api +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {array} models.APITokenRoute "The list of all routes." +// @Router /routes [get] +func GetAvailableAPIRoutesForToken(c echo.Context) error { + return c.JSON(http.StatusOK, apiTokenRoutes) +} + +// CanDoAPIRoute checks if a token is allowed to use the current api route +func CanDoAPIRoute(c echo.Context, token *APIToken) (can bool) { + path := c.Request().URL.Path + routeGroupName := getRouteGroupName(path) + + group, hasGroup := token.Permissions[routeGroupName] + if !hasGroup { + return false + } + + var route string + routes, has := apiTokenRoutes[routeGroupName] + if !has { + return false + } + + if routes.Create != nil && routes.Create.Path == path && routes.Create.Method == c.Request().Method { + route = "create" + } + if routes.ReadOne != nil && routes.ReadOne.Path == path && routes.ReadOne.Method == c.Request().Method { + route = "read_one" + } + if routes.ReadAll != nil && routes.ReadAll.Path == path && routes.ReadAll.Method == c.Request().Method { + route = "read_all" + } + if routes.Update != nil && routes.Update.Path == path && routes.Update.Method == c.Request().Method { + route = "update" + } + if routes.Delete != nil && routes.Delete.Path == path && routes.Delete.Method == c.Request().Method { + route = "delete" + } + + for _, p := range group { + if p == route { + return true + } + } + + return false +} + +func PermissionsAreValid(permissions APIPermissions) (err error) { + + for key, methods := range permissions { + routes, has := apiTokenRoutes[key] + if !has { + return &ErrInvalidAPITokenPermission{ + Group: key, + } + } + + for _, method := range methods { + if method == "create" && routes.Create == nil { + return &ErrInvalidAPITokenPermission{ + Group: key, + Permission: method, + } + } + if method == "read_one" && routes.ReadOne == nil { + return &ErrInvalidAPITokenPermission{ + Group: key, + Permission: method, + } + } + if method == "read_all" && routes.ReadAll == nil { + return &ErrInvalidAPITokenPermission{ + Group: key, + Permission: method, + } + } + if method == "update" && routes.Update == nil { + return &ErrInvalidAPITokenPermission{ + Group: key, + Permission: method, + } + } + if method == "delete" && routes.Delete == nil { + return &ErrInvalidAPITokenPermission{ + Group: key, + Permission: method, + } + } + } + } + + return nil +} diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go new file mode 100644 index 00000000000..637da13cabf --- /dev/null +++ b/pkg/models/api_tokens.go @@ -0,0 +1,191 @@ +// 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 ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "time" + + "xorm.io/builder" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/utils" + + "code.vikunja.io/web" + "golang.org/x/crypto/pbkdf2" + "xorm.io/xorm" +) + +type APIPermissions map[string][]string + +type APIToken struct { + // The unique, numeric id of this api key. + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"` + + // A human-readable name for this token + Title string `xorm:"not null" json:"title" valid:"required"` + // The actual api key. Only visible after creation. + Token string `xorm:"-" json:"token,omitempty"` + TokenSalt string `xorm:"not null" json:"-"` + TokenHash string `xorm:"not null unique" json:"-"` + TokenLastEight string `xorm:"not null index varchar(8)" json:"-"` + // The permissions this token has. Possible values are available via the /routes endpoint and consist of the keys of the list from that endpoint. For example, if the token should be able to read all tasks as well as update existing tasks, you should add `{"tasks":["read_all","update"]}`. + Permissions APIPermissions `xorm:"json not null" json:"permissions" valid:"required"` + // The date when this key expires. + ExpiresAt time.Time `xorm:"not null" json:"expires_at" valid:"required"` + + // A timestamp when this api key was created. You cannot change this value. + Created time.Time `xorm:"created not null" json:"created"` + + OwnerID int64 `xorm:"bigint not null" json:"-"` + + web.Rights `xorm:"-" json:"-"` + web.CRUDable `xorm:"-" json:"-"` +} + +const APITokenPrefix = `tk_` + +func (*APIToken) TableName() string { + return "api_tokens" +} + +func GetAPITokenByID(s *xorm.Session, id int64) (token *APIToken, err error) { + token = &APIToken{} + _, err = s.Where("id = ?", id). + Get(token) + return +} + +// Create creates a new token +// @Summary Create a new api token +// @Description Create a new api token to use on behalf of the user creating it. +// @tags api +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param token body models.APIToken true "The token object with required fields" +// @Success 200 {object} models.APIToken "The created token." +// @Failure 400 {object} web.HTTPError "Invalid token object provided." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tokens [put] +func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { + t.ID = 0 + + salt, err := utils.CryptoRandomString(10) + if err != nil { + return err + } + token, err := utils.CryptoRandomBytes(20) + if err != nil { + return err + } + t.TokenSalt = salt + t.Token = APITokenPrefix + hex.EncodeToString(token) + t.TokenHash = HashToken(t.Token, t.TokenSalt) + t.TokenLastEight = t.Token[len(t.Token)-8:] + + t.OwnerID = a.GetID() + + if err := PermissionsAreValid(t.Permissions); err != nil { + return err + } + + _, err = s.Insert(t) + return err +} + +func HashToken(token, salt string) string { + tempHash := pbkdf2.Key([]byte(token), []byte(salt), 10000, 50, sha256.New) + return hex.EncodeToString(tempHash) +} + +// ReadAll returns all api tokens the current user has created +// @Summary Get all api tokens of the current user +// @Description Returns all api tokens the current user has created. +// @tags api +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param page query int false "The page number for tasks. Used for pagination. If not provided, the first page of results is returned." +// @Param per_page query int false "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page." +// @Param s query string false "Search tasks by task text." +// @Success 200 {array} models.APIToken "The list of all tokens" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /tokens [get] +func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { + + tokens := []*APIToken{} + + var where builder.Cond = builder.Eq{"owner_id": a.GetID()} + + if search != "" { + where = builder.And( + where, + db.ILIKE("title", search), + ) + } + + err = s. + Where(where). + Limit(getLimitFromPageIndex(page, perPage)). + Find(&tokens) + if err != nil { + return nil, 0, 0, err + } + + totalCount, err := s.Where(where).Count(&APIToken{}) + return tokens, len(tokens), totalCount, err +} + +// Delete deletes a token +// @Summary Deletes an existing api token +// @Description Delete any of the user's api tokens. +// @tags api +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param tokenID path int true "Token ID" +// @Success 200 {object} models.Message "Successfully deleted." +// @Failure 404 {object} web.HTTPError "The token does not exist." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tokens/{tokenID} [delete] +func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) { + _, err = s.Where("id = ? AND owner_id = ?", t.ID, a.GetID()).Delete(&APIToken{}) + return err +} + +// GetTokenFromTokenString returns the full token object from the original token string. +func GetTokenFromTokenString(s *xorm.Session, token string) (apiToken *APIToken, err error) { + lastEight := token[len(token)-8:] + + tokens := []*APIToken{} + err = s.Where("token_last_eight = ?", lastEight).Find(&tokens) + if err != nil { + return nil, err + } + + for _, t := range tokens { + tempHash := HashToken(token, t.TokenSalt) + if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 { + return t, nil + } + } + + return nil, &ErrAPITokenInvalid{} +} diff --git a/pkg/models/api_tokens_rights.go b/pkg/models/api_tokens_rights.go new file mode 100644 index 00000000000..e687fd88b31 --- /dev/null +++ b/pkg/models/api_tokens_rights.go @@ -0,0 +1,40 @@ +// 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 ( + "code.vikunja.io/web" + "xorm.io/xorm" +) + +func (t *APIToken) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + token, err := GetAPITokenByID(s, t.ID) + if err != nil { + return false, err + } + + if token.OwnerID != a.GetID() { + return false, nil + } + + *t = *token + return true, nil +} + +func (t *APIToken) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { + return true, nil +} diff --git a/pkg/models/api_tokens_test.go b/pkg/models/api_tokens_test.go new file mode 100644 index 00000000000..ad5ee119f74 --- /dev/null +++ b/pkg/models/api_tokens_test.go @@ -0,0 +1,118 @@ +// 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) + assert.NoError(t, err) + }) +} + +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/error.go b/pkg/models/error.go index 1bfa1a59f29..73866ad7a87 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1655,3 +1655,61 @@ func (err ErrLinkShareTokenInvalid) HTTPError() web.HTTPError { Message: "The provided link share token is invalid.", } } + +// ================ +// API Token Errors +// ================ + +// ErrAPITokenInvalid represents an error where an api token is invalid +type ErrAPITokenInvalid struct { +} + +// IsErrAPITokenInvalid checks if an error is ErrAPITokenInvalid. +func IsErrAPITokenInvalid(err error) bool { + _, ok := err.(*ErrAPITokenInvalid) + return ok +} + +func (err *ErrAPITokenInvalid) Error() string { + return "Provided API token is invalid" +} + +// ErrCodeAPITokenInvalid holds the unique world-error code of this error +const ErrCodeAPITokenInvalid = 14001 + +// HTTPError holds the http error description +func (err ErrAPITokenInvalid) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeAPITokenInvalid, + Message: "The provided api token is invalid.", + } +} + +// ErrInvalidAPITokenPermission represents an error where an api token is invalid +type ErrInvalidAPITokenPermission struct { + Group string + Permission string +} + +// IsErrInvalidAPITokenPermission checks if an error is ErrInvalidAPITokenPermission. +func IsErrInvalidAPITokenPermission(err error) bool { + _, ok := err.(*ErrInvalidAPITokenPermission) + return ok +} + +func (err *ErrInvalidAPITokenPermission) Error() string { + return fmt.Sprintf("API token permission %s of group %s is invalid", err.Permission, err.Group) +} + +// ErrCodeInvalidAPITokenPermission holds the unique world-error code of this error +const ErrCodeInvalidAPITokenPermission = 14002 + +// HTTPError holds the http error description +func (err ErrInvalidAPITokenPermission) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidAPITokenPermission, + Message: fmt.Sprintf("The permission %s of group %s is invalid.", err.Permission, err.Group), + } +} 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) diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 761d9eb551b..164ea51e0d9 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -17,9 +17,12 @@ package auth import ( + "fmt" "net/http" "time" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" @@ -101,7 +104,20 @@ func NewLinkShareJWTAuthtoken(share *models.LinkSharing) (token string, err erro // GetAuthFromClaims returns a web.Auth object from jwt claims func GetAuthFromClaims(c echo.Context) (a web.Auth, err error) { - jwtinf := c.Get("user").(*jwt.Token) + // check if we have a token in context and use it if that's the case + if c.Get("api_token") != nil { + apiToken := c.Get("api_token").(*models.APIToken) + u, err := user.GetUserByID(db.NewSession(), apiToken.OwnerID) + if err != nil { + return nil, err + } + return u, nil + } + + jwtinf, is := c.Get("user").(*jwt.Token) + if !is { + return nil, fmt.Errorf("user in context is not jwt token") + } claims := jwtinf.Claims.(jwt.MapClaims) typ := int(claims["type"].(float64)) if typ == AuthTypeLinkShare && config.ServiceEnableLinkSharing.GetBool() { diff --git a/pkg/routes/api/v1/docs.go b/pkg/routes/api/v1/docs.go index 9d095e6d323..68d4954727a 100644 --- a/pkg/routes/api/v1/docs.go +++ b/pkg/routes/api/v1/docs.go @@ -22,6 +22,8 @@ import ( "net/http" "code.vikunja.io/api/pkg/log" + _ "code.vikunja.io/api/pkg/swagger" // To make sure the swag files are properly registered + "github.com/labstack/echo/v4" "github.com/swaggo/swag" ) diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go new file mode 100644 index 00000000000..93c9a39a435 --- /dev/null +++ b/pkg/routes/api_tokens.go @@ -0,0 +1,79 @@ +// 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 routes + +import ( + "net/http" + "strings" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" +) + +func SetupTokenMiddleware() echo.MiddlewareFunc { + return echojwt.WithConfig(echojwt.Config{ + SigningKey: []byte(config.ServiceJWTSecret.GetString()), + Skipper: func(c echo.Context) bool { + authHeader := c.Request().Header.Values("Authorization") + if len(authHeader) == 0 { + return false // let the jwt middleware handle invalid headers + } + + for _, s := range authHeader { + if strings.HasPrefix(s, "Bearer "+models.APITokenPrefix) { + err := checkAPITokenAndPutItInContext(s, c) + return err == nil + } + } + + return false + }, + ErrorHandler: func(c echo.Context, err error) error { + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "missing, malformed, expired or otherwise invalid token provided").SetInternal(err) + } + + return nil + }, + }) +} + +func checkAPITokenAndPutItInContext(tokenHeaderValue string, c echo.Context) error { + s := db.NewSession() + defer s.Close() + token, err := models.GetTokenFromTokenString(s, strings.TrimPrefix(tokenHeaderValue, "Bearer ")) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err) + } + + if time.Now().After(token.ExpiresAt) { + return echo.NewHTTPError(http.StatusUnauthorized) + } + + if !models.CanDoAPIRoute(c, token) { + return echo.NewHTTPError(http.StatusUnauthorized) + } + + c.Set("api_token", token) + + return nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index cb3c81dfe95..b618ab3b8fb 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -30,6 +30,8 @@ // @description # Authorization // @description **JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer `-header to authenticate successfully. // @description +// @description **API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer ` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation. +// @description // @description **BasicAuth:** Only used when requesting tasks via CalDAV. // @description // @BasePath /api/v1 @@ -80,7 +82,6 @@ import ( "github.com/getsentry/sentry-go" sentryecho "github.com/getsentry/sentry-go/echo" - echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" elog "github.com/labstack/gommon/log" @@ -202,6 +203,9 @@ func RegisterRoutes(e *echo.Echo) { // API Routes a := e.Group("/api/v1") + e.OnAddRouteHandler = func(host string, route echo.Route, handler echo.HandlerFunc, middleware []echo.MiddlewareFunc) { + models.CollectRoutesForAPITokenUsage(route) + } registerAPIRoutes(a) } @@ -277,7 +281,7 @@ func registerAPIRoutes(a *echo.Group) { } // ===== Routes with Authentication ===== - a.Use(echojwt.JWT([]byte(config.ServiceJWTSecret.GetString()))) + a.Use(SetupTokenMiddleware()) // Rate limit setupRateLimit(a, config.RateLimitKind.GetString()) @@ -286,6 +290,7 @@ func registerAPIRoutes(a *echo.Group) { setupMetricsMiddleware(a) a.POST("/tokenTest", apiv1.CheckToken) + a.GET("/routes", models.GetAvailableAPIRoutesForToken) // User stuff u := a.Group("/user") @@ -559,6 +564,16 @@ func registerAPIRoutes(a *echo.Group) { a.GET("/backgrounds/unsplash/images/:image", unsplash.ProxyUnsplashImage) } } + + // API Tokens + apiTokenProvider := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.APIToken{} + }, + } + a.GET("/tokens", apiTokenProvider.ReadAllWeb) + a.PUT("/tokens", apiTokenProvider.CreateWeb) + a.DELETE("/tokens/:token", apiTokenProvider.DeleteWeb) } func registerMigrations(m *echo.Group) { diff --git a/pkg/utils/random.go b/pkg/utils/random.go new file mode 100644 index 00000000000..e2304650b2a --- /dev/null +++ b/pkg/utils/random.go @@ -0,0 +1,72 @@ +// 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 utils + +import ( + "code.vikunja.io/api/pkg/log" + + "crypto/rand" + "math/big" +) + +// MakeRandomString return a random string +// Deprecated: use CryptoRandomString instead +func MakeRandomString(n int) string { + str, err := CryptoRandomString(int64(n)) + if err != nil { + log.Errorf("Could not generate random string: %s", err) + } + + return str +} + +// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive +// Copied from https://github.com/go-gitea/gitea/blob/main/modules/util/util.go#L121-L127 +func CryptoRandomInt(limit int64) (int64, error) { + rInt, err := rand.Int(rand.Reader, big.NewInt(limit)) + if err != nil { + return 0, err + } + return rInt.Int64(), nil +} + +const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// CryptoRandomString generates a crypto random alphanumerical string, each byte is generated by [0,61] range +// Copied from https://github.com/go-gitea/gitea/blob/main/modules/util/util.go#L131-L143 +func CryptoRandomString(length int64) (string, error) { + buf := make([]byte, length) + limit := int64(len(alphanumericalChars)) + for i := range buf { + num, err := CryptoRandomInt(limit) + if err != nil { + return "", err + } + buf[i] = alphanumericalChars[num] + } + return string(buf), nil +} + +// CryptoRandomBytes generates `length` crypto bytes +// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range +// This function generates totally random bytes, each byte is generated by [0,255] range +// Copied from https://github.com/go-gitea/gitea/blob/main/modules/util/util.go#L145-L152 +func CryptoRandomBytes(length int64) ([]byte, error) { + buf := make([]byte, length) + _, err := rand.Read(buf) + return buf, err +} diff --git a/pkg/utils/random_string.go b/pkg/utils/random_string.go deleted file mode 100644 index 2ae71a274bd..00000000000 --- a/pkg/utils/random_string.go +++ /dev/null @@ -1,49 +0,0 @@ -// 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 utils - -import ( - "math/rand" - "time" -) - -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" -const ( - letterIdxBits = 6 // 6 bits to represent a letter index - letterIdxMask = 1<= 0; { - if remain == 0 { - cache, remain = source.Int63(), letterIdxMax - } - if idx := int(cache & letterIdxMask); idx < len(letterBytes) { - b[i] = letterBytes[idx] - i-- - } - cache >>= letterIdxBits - remain-- - } - - return string(b) -} diff --git a/pkg/utils/random_string_test.go b/pkg/utils/random_test.go similarity index 100% rename from pkg/utils/random_string_test.go rename to pkg/utils/random_test.go