From 1ca93a678e6d931aa3afb3aaa654763ee8304d3b Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 17:02:25 +0200 Subject: [PATCH 01/17] feat: endpoint to get all token routes --- pkg/routes/api_routes_list.go | 104 ++++++++++++++++++++++++++++++++++ pkg/routes/routes.go | 4 ++ 2 files changed, 108 insertions(+) create mode 100644 pkg/routes/api_routes_list.go diff --git a/pkg/routes/api_routes_list.go b/pkg/routes/api_routes_list.go new file mode 100644 index 000000000..8737a4ae7 --- /dev/null +++ b/pkg/routes/api_routes_list.go @@ -0,0 +1,104 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2023 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 ( + "github.com/labstack/echo/v4" + "net/http" + "strings" +) + +var apiTokenRoutes = map[string]*APITokenRoute{} + +func init() { + apiTokenRoutes = make(map[string]*APITokenRoute) +} + +type APITokenRoute struct { + Create string `json:"create,omitempty"` + ReadOne string `json:"read_one,omitempty"` + ReadAll string `json:"read_all,omitempty"` + Update string `json:"update,omitempty"` + Delete string `json:"delete,omitempty"` +} + +func getRouteGroupName(route echo.Route) string { + parts := strings.Split(strings.TrimPrefix(route.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 + } +} + +// 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) + + if routeGroupName == "subscriptions" || routeGroupName == "notifications" || strings.HasSuffix(routeGroupName, "_bulk") { + return + } + + _, has := apiTokenRoutes[routeGroupName] + if !has { + apiTokenRoutes[routeGroupName] = &APITokenRoute{} + } + + if strings.Contains(route.Name, "CreateWeb") { + apiTokenRoutes[routeGroupName].Create = route.Path + } + if strings.Contains(route.Name, "ReadWeb") { + apiTokenRoutes[routeGroupName].ReadOne = route.Path + } + if strings.Contains(route.Name, "ReadAllWeb") { + apiTokenRoutes[routeGroupName].ReadAll = route.Path + } + if strings.Contains(route.Name, "UpdateWeb") { + apiTokenRoutes[routeGroupName].Update = route.Path + } + if strings.Contains(route.Name, "DeleteWeb") { + apiTokenRoutes[routeGroupName].Delete = route.Path + } +} + +// 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 opi +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {array} routes.APITokenRoute "The list of all routes." +// @Router /routes [get] +func GetAvailableAPIRoutesForToken(c echo.Context) error { + return c.JSON(http.StatusOK, apiTokenRoutes) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index cb3c81dfe..509e5874d 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -202,6 +202,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) { + collectRoutesForAPITokenUsage(route) + } registerAPIRoutes(a) } @@ -286,6 +289,7 @@ func registerAPIRoutes(a *echo.Group) { setupMetricsMiddleware(a) a.POST("/tokenTest", apiv1.CheckToken) + a.GET("/routes", GetAvailableAPIRoutesForToken) // User stuff u := a.Group("/user") From 3faf48706aae963a381544660a19d0ef1079c6ba Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 17:02:46 +0200 Subject: [PATCH 02/17] feat(api tokens): add api token struct and migration --- pkg/migration/20230831155832.go | 51 ++++++++++++++++++++++++++++++++ pkg/models/api_tokens.go | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 pkg/migration/20230831155832.go create mode 100644 pkg/models/api_tokens.go diff --git a/pkg/migration/20230831155832.go b/pkg/migration/20230831155832.go new file mode 100644 index 000000000..da466e8f4 --- /dev/null +++ b/pkg/migration/20230831155832.go @@ -0,0 +1,51 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 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 ( + "src.techknowlogick.com/xormigrate" + "time" + "xorm.io/xorm" +) + +type api_tokens20230831155832 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"` + Title string `xorm:"not null" json:"title"` + Key string `xorm:"not null varchar(50)" json:"key"` + Permissions map[string][]string `xorm:"json not null" json:"permissions"` + ExpiresAt time.Time `xorm:"not null" json:"expires_at"` + Created time.Time `xorm:"created not null" json:"created"` + Updated time.Time `xorm:"updated not null" json:"updated"` + OwnerID int64 `xorm:"bigint not null" json:"-"` +} + +func (api_tokens20230831155832) TableName() string { + return "api_tokens" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20230831155832", + Description: "", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(api_tokens20230831155832{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go new file mode 100644 index 000000000..d4347cc95 --- /dev/null +++ b/pkg/models/api_tokens.go @@ -0,0 +1,52 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2023 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" + "time" +) + +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"` + // The actual api key. Only visible after creation. + Key string `xorm:"not null varchar(50)" json:"key,omitempty"` + // The permissions this key 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"` + // The date when this key expires. + ExpiresAt time.Time `xorm:"not null" json:"expires_at"` + + // A timestamp when this api key was created. You cannot change this value. + Created time.Time `xorm:"created not null" json:"created"` + // A timestamp when this api key was last updated. You cannot change this value. + Updated time.Time `xorm:"updated not null" json:"updated"` + + OwnerID int64 `xorm:"bigint not null" json:"-"` + + web.Rights `xorm:"-" json:"-"` + web.CRUDable `xorm:"-" json:"-"` +} + +func (*APIToken) TableName() string { + return "api_tokens" +} From e6b25bd57b537ef9a72b5acdadf446ca5ef77bfa Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 20:37:25 +0200 Subject: [PATCH 03/17] feat(api tokens): add crud routes to manage api tokens --- pkg/models/api_tokens.go | 99 +++++++++++++++++++++++++++++++-- pkg/models/api_tokens_rights.go | 40 +++++++++++++ pkg/routes/api/v1/docs.go | 2 + pkg/routes/api_routes_list.go | 12 ++-- pkg/routes/routes.go | 10 ++++ 5 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 pkg/models/api_tokens_rights.go diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index d4347cc95..074afc30d 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -17,8 +17,13 @@ package models import ( - "code.vikunja.io/web" "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/utils" + + "code.vikunja.io/web" + "xorm.io/xorm" ) type APIPermissions map[string][]string @@ -28,13 +33,13 @@ type APIToken struct { 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"` + Title string `xorm:"not null" json:"title" valid:"required"` // The actual api key. Only visible after creation. Key string `xorm:"not null varchar(50)" json:"key,omitempty"` - // The permissions this key 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"` + // 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"` + 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"` @@ -50,3 +55,87 @@ type APIToken struct { 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 + t.Key = "tk_" + utils.MakeRandomString(32) + t.OwnerID = a.GetID() + + // TODO: validate permissions + + _, err = s.Insert(t) + return err +} + +// 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{} + + query := s.Where("owner_id = ?", a.GetID()). + Limit(getLimitFromPageIndex(page, perPage)) + + if search != "" { + query = query.Where(db.ILIKE("title", search)) + } + + err = query.Find(&tokens) + if err != nil { + return nil, 0, 0, err + } + + for _, token := range tokens { + token.Key = "" + } + + totalCount, err := query.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 +} diff --git a/pkg/models/api_tokens_rights.go b/pkg/models/api_tokens_rights.go new file mode 100644 index 000000000..18aeab7c9 --- /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-2023 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(s *xorm.Session, a web.Auth) (bool, error) { + return true, nil +} diff --git a/pkg/routes/api/v1/docs.go b/pkg/routes/api/v1/docs.go index 9d095e6d3..68d495472 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_routes_list.go b/pkg/routes/api_routes_list.go index 8737a4ae7..cb5594707 100644 --- a/pkg/routes/api_routes_list.go +++ b/pkg/routes/api_routes_list.go @@ -17,9 +17,10 @@ package routes import ( - "github.com/labstack/echo/v4" "net/http" "strings" + + "github.com/labstack/echo/v4" ) var apiTokenRoutes = map[string]*APITokenRoute{} @@ -56,7 +57,7 @@ func getRouteGroupName(route echo.Route) string { } } -// gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens. +// 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)") { @@ -65,7 +66,10 @@ func collectRoutesForAPITokenUsage(route echo.Route) { routeGroupName := getRouteGroupName(route) - if routeGroupName == "subscriptions" || routeGroupName == "notifications" || strings.HasSuffix(routeGroupName, "_bulk") { + if routeGroupName == "subscriptions" || + routeGroupName == "notifications" || + routeGroupName == "tokens" || + strings.HasSuffix(routeGroupName, "_bulk") { return } @@ -94,7 +98,7 @@ func collectRoutesForAPITokenUsage(route echo.Route) { // 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 opi +// @tags api // @Produce json // @Security JWTKeyAuth // @Success 200 {array} routes.APITokenRoute "The list of all routes." diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 509e5874d..ba872d98d 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -563,6 +563,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) { From c88cbaa973bd15bdeb21dcda68afb2814e96ab81 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 21:05:16 +0200 Subject: [PATCH 04/17] feat(api tokens): properly hash tokens --- pkg/migration/20230831155832.go | 18 +++-- pkg/models/api_tokens.go | 53 +++++++++++-- pkg/models/error.go | 30 +++++++ pkg/utils/random.go | 79 +++++++++++++++++++ pkg/utils/random_string.go | 49 ------------ .../{random_string_test.go => random_test.go} | 0 6 files changed, 166 insertions(+), 63 deletions(-) create mode 100644 pkg/utils/random.go delete mode 100644 pkg/utils/random_string.go rename pkg/utils/{random_string_test.go => random_test.go} (100%) diff --git a/pkg/migration/20230831155832.go b/pkg/migration/20230831155832.go index da466e8f4..cda7ba1ce 100644 --- a/pkg/migration/20230831155832.go +++ b/pkg/migration/20230831155832.go @@ -23,14 +23,16 @@ import ( ) type api_tokens20230831155832 struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"` - Title string `xorm:"not null" json:"title"` - Key string `xorm:"not null varchar(50)" json:"key"` - Permissions map[string][]string `xorm:"json not null" json:"permissions"` - ExpiresAt time.Time `xorm:"not null" json:"expires_at"` - Created time.Time `xorm:"created not null" json:"created"` - Updated time.Time `xorm:"updated not null" json:"updated"` - OwnerID int64 `xorm:"bigint not null" json:"-"` + 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"` + Created time.Time `xorm:"created not null" json:"created"` + Updated time.Time `xorm:"updated not null" json:"updated"` + OwnerID int64 `xorm:"bigint not null" json:"-"` } func (api_tokens20230831155832) TableName() string { diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 074afc30d..ed2e5cb8e 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -17,6 +17,10 @@ package models import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "golang.org/x/crypto/pbkdf2" "time" "code.vikunja.io/api/pkg/db" @@ -35,7 +39,10 @@ type APIToken struct { // A human-readable name for this token Title string `xorm:"not null" json:"title" valid:"required"` // The actual api key. Only visible after creation. - Key string `xorm:"not null varchar(50)" json:"key,omitempty"` + Token string `xorm:"-" json:"key,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. @@ -77,7 +84,20 @@ func GetAPITokenByID(s *xorm.Session, id int64) (token *APIToken, err error) { // @Router /tokens [put] func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { t.ID = 0 - t.Key = "tk_" + utils.MakeRandomString(32) + + 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 = "tk_" + hex.EncodeToString(token) + t.TokenHash = HashToken(t.Token, t.TokenSalt) + t.TokenLastEight = t.Token[len(t.Token)-8:] + t.OwnerID = a.GetID() // TODO: validate permissions @@ -86,6 +106,11 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { 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. @@ -115,10 +140,6 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int, return nil, 0, 0, err } - for _, token := range tokens { - token.Key = "" - } - totalCount, err := query.Count(&APIToken{}) return tokens, len(tokens), totalCount, err } @@ -139,3 +160,23 @@ 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/error.go b/pkg/models/error.go index 1bfa1a59f..6bc62d58d 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1655,3 +1655,33 @@ 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.", + } +} diff --git a/pkg/utils/random.go b/pkg/utils/random.go new file mode 100644 index 000000000..bed090df9 --- /dev/null +++ b/pkg/utils/random.go @@ -0,0 +1,79 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 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" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +const ( + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<. - -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 From fb2a1c59db7f922a373b94e5cb5af92694ffadcf Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 21:39:26 +0200 Subject: [PATCH 05/17] feat(api tokens): check if a provided token matched a hashed on in the database --- pkg/models/api_tokens.go | 6 ++++-- pkg/modules/auth/auth.go | 17 ++++++++++++++++- pkg/routes/routes.go | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index ed2e5cb8e..4a037aeba 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -39,7 +39,7 @@ type APIToken struct { // 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:"key,omitempty"` + 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:"-"` @@ -59,6 +59,8 @@ type APIToken struct { web.CRUDable `xorm:"-" json:"-"` } +const APITokenPrefix = `tk_` + func (*APIToken) TableName() string { return "api_tokens" } @@ -94,7 +96,7 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { return err } t.TokenSalt = salt - t.Token = "tk_" + hex.EncodeToString(token) + t.Token = APITokenPrefix + hex.EncodeToString(token) t.TokenHash = HashToken(t.Token, t.TokenSalt) t.TokenLastEight = t.Token[len(t.Token)-8:] diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 761d9eb55..f61a8e99d 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -17,6 +17,8 @@ package auth import ( + "code.vikunja.io/api/pkg/db" + "fmt" "net/http" "time" @@ -101,7 +103,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/routes.go b/pkg/routes/routes.go index ba872d98d..4350d17ab 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -51,6 +51,7 @@ package routes import ( "errors" + "net/http" "net/url" "strings" "time" @@ -280,7 +281,42 @@ func registerAPIRoutes(a *echo.Group) { } // ===== Routes with Authentication ===== - a.Use(echojwt.JWT([]byte(config.ServiceJWTSecret.GetString()))) + a.Use(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) { + c.Set("api_token", s) + return true + } + } + + return false + }, + })) + + a.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // If this is empty we assume we're dealing with a "real" user who has provided a jwt + tokenString, is := c.Get("api_token").(string) + if !is || tokenString == "" { + return next(c) + } + token, err := models.GetTokenFromTokenString(db.NewSession(), strings.TrimPrefix(tokenString, "Bearer ")) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err) + } + + c.Set("api_token", token) + + return next(c) + } + }) // Rate limit setupRateLimit(a, config.RateLimitKind.GetString()) From 677bd5cfc977e3295e760c435422e9e229b880f1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 21:40:43 +0200 Subject: [PATCH 06/17] feat(api tokens): check for expiry date --- pkg/routes/routes.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 4350d17ab..0c84e73f9 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -312,6 +312,10 @@ func registerAPIRoutes(a *echo.Group) { return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err) } + if token.ExpiresAt.After(time.Now()) { + return echo.NewHTTPError(http.StatusUnauthorized) + } + c.Set("api_token", token) return next(c) From 5c6c6cd9f0db369d13a0a7315d15e5cf7a29c7f4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 21:59:20 +0200 Subject: [PATCH 07/17] feat(api tokens): check for scopes --- .../{api_routes_list.go => api_routes.go} | 87 ++++++++++++++++--- pkg/routes/routes.go | 6 +- 2 files changed, 79 insertions(+), 14 deletions(-) rename pkg/routes/{api_routes_list.go => api_routes.go} (54%) diff --git a/pkg/routes/api_routes_list.go b/pkg/routes/api_routes.go similarity index 54% rename from pkg/routes/api_routes_list.go rename to pkg/routes/api_routes.go index cb5594707..85da44094 100644 --- a/pkg/routes/api_routes_list.go +++ b/pkg/routes/api_routes.go @@ -17,6 +17,7 @@ package routes import ( + "code.vikunja.io/api/pkg/models" "net/http" "strings" @@ -30,15 +31,20 @@ func init() { } type APITokenRoute struct { - Create string `json:"create,omitempty"` - ReadOne string `json:"read_one,omitempty"` - ReadAll string `json:"read_all,omitempty"` - Update string `json:"update,omitempty"` - Delete string `json:"delete,omitempty"` + 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"` } -func getRouteGroupName(route echo.Route) string { - parts := strings.Split(strings.TrimPrefix(route.Path, "/api/v1/"), "/") +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, ":") { @@ -64,7 +70,7 @@ func collectRoutesForAPITokenUsage(route echo.Route) { return } - routeGroupName := getRouteGroupName(route) + routeGroupName := getRouteGroupName(route.Path) if routeGroupName == "subscriptions" || routeGroupName == "notifications" || @@ -79,19 +85,34 @@ func collectRoutesForAPITokenUsage(route echo.Route) { } if strings.Contains(route.Name, "CreateWeb") { - apiTokenRoutes[routeGroupName].Create = route.Path + apiTokenRoutes[routeGroupName].Create = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } } if strings.Contains(route.Name, "ReadWeb") { - apiTokenRoutes[routeGroupName].ReadOne = route.Path + apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } } if strings.Contains(route.Name, "ReadAllWeb") { - apiTokenRoutes[routeGroupName].ReadAll = route.Path + apiTokenRoutes[routeGroupName].ReadAll = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } } if strings.Contains(route.Name, "UpdateWeb") { - apiTokenRoutes[routeGroupName].Update = route.Path + apiTokenRoutes[routeGroupName].Update = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } } if strings.Contains(route.Name, "DeleteWeb") { - apiTokenRoutes[routeGroupName].Delete = route.Path + apiTokenRoutes[routeGroupName].Delete = &RouteDetail{ + Path: route.Path, + Method: route.Method, + } } } @@ -106,3 +127,43 @@ func collectRoutesForAPITokenUsage(route echo.Route) { 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 *models.APIToken) (can bool) { + routeGroupName := getRouteGroupName(c.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 == c.Path() && routes.Create.Method == c.Request().Method { + route = "create" + } + if routes.ReadOne != nil && routes.ReadOne.Path == c.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 { + route = "read_all" + } + if routes.Update != nil && routes.Update.Path == c.Path() && routes.Update.Method == c.Request().Method { + route = "update" + } + if routes.Delete != nil && routes.Delete.Path == c.Path() && routes.Delete.Method == c.Request().Method { + route = "delete" + } + + for _, p := range group { + if p == route { + return true + } + } + + return false +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 0c84e73f9..de54cf42a 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -312,7 +312,11 @@ func registerAPIRoutes(a *echo.Group) { return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err) } - if token.ExpiresAt.After(time.Now()) { + if time.Now().After(token.ExpiresAt) { + return echo.NewHTTPError(http.StatusUnauthorized) + } + + if !CanDoAPIRoute(c, token) { return echo.NewHTTPError(http.StatusUnauthorized) } From e4c71123ef91480d41284288bee38939cd17ae39 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 08:39:12 +0200 Subject: [PATCH 08/17] fix: lint --- pkg/migration/20230831155832.go | 11 ++++++----- pkg/models/api_tokens.go | 4 ++-- pkg/models/api_tokens_rights.go | 4 ++-- pkg/modules/auth/auth.go | 3 ++- pkg/routes/api_routes.go | 5 +++-- pkg/utils/random.go | 9 +-------- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/pkg/migration/20230831155832.go b/pkg/migration/20230831155832.go index cda7ba1ce..0eb616345 100644 --- a/pkg/migration/20230831155832.go +++ b/pkg/migration/20230831155832.go @@ -1,5 +1,5 @@ // Vikunja is a to-do list application to facilitate your life. -// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// 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 @@ -17,12 +17,13 @@ package migration import ( - "src.techknowlogick.com/xormigrate" "time" + + "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) -type api_tokens20230831155832 struct { +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:"-"` @@ -35,7 +36,7 @@ type api_tokens20230831155832 struct { OwnerID int64 `xorm:"bigint not null" json:"-"` } -func (api_tokens20230831155832) TableName() string { +func (apiTokens20230831155832) TableName() string { return "api_tokens" } @@ -44,7 +45,7 @@ func init() { ID: "20230831155832", Description: "", Migrate: func(tx *xorm.Engine) error { - return tx.Sync2(api_tokens20230831155832{}) + return tx.Sync2(apiTokens20230831155832{}) }, Rollback: func(tx *xorm.Engine) error { return nil diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 4a037aeba..2794daa03 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -1,5 +1,5 @@ // Vikunja is a to-do list application to facilitate your life. -// Copyright 2018-2023 Vikunja and contributors. All rights reserved. +// 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 @@ -20,13 +20,13 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" - "golang.org/x/crypto/pbkdf2" "time" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/utils" "code.vikunja.io/web" + "golang.org/x/crypto/pbkdf2" "xorm.io/xorm" ) diff --git a/pkg/models/api_tokens_rights.go b/pkg/models/api_tokens_rights.go index 18aeab7c9..e687fd88b 100644 --- a/pkg/models/api_tokens_rights.go +++ b/pkg/models/api_tokens_rights.go @@ -1,5 +1,5 @@ // Vikunja is a to-do list application to facilitate your life. -// Copyright 2018-2023 Vikunja and contributors. All rights reserved. +// 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 @@ -35,6 +35,6 @@ func (t *APIToken) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { return true, nil } -func (t *APIToken) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { +func (t *APIToken) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil } diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index f61a8e99d..164ea51e0 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -17,11 +17,12 @@ package auth import ( - "code.vikunja.io/api/pkg/db" "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" diff --git a/pkg/routes/api_routes.go b/pkg/routes/api_routes.go index 85da44094..d25e68480 100644 --- a/pkg/routes/api_routes.go +++ b/pkg/routes/api_routes.go @@ -1,5 +1,5 @@ // Vikunja is a to-do list application to facilitate your life. -// Copyright 2018-2023 Vikunja and contributors. All rights reserved. +// 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 @@ -17,10 +17,11 @@ package routes import ( - "code.vikunja.io/api/pkg/models" "net/http" "strings" + "code.vikunja.io/api/pkg/models" + "github.com/labstack/echo/v4" ) diff --git a/pkg/utils/random.go b/pkg/utils/random.go index bed090df9..e2304650b 100644 --- a/pkg/utils/random.go +++ b/pkg/utils/random.go @@ -1,5 +1,5 @@ // Vikunja is a to-do list application to facilitate your life. -// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// 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 @@ -23,13 +23,6 @@ import ( "math/big" ) -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" -const ( - letterIdxBits = 6 // 6 bits to represent a letter index - letterIdxMask = 1< Date: Fri, 1 Sep 2023 08:52:57 +0200 Subject: [PATCH 09/17] feat(api tokens): check permissions when saving --- pkg/{routes => models}/api_routes.go | 57 +++++++++++++++++++++++++--- pkg/models/api_tokens.go | 4 +- pkg/models/error.go | 28 ++++++++++++++ pkg/routes/routes.go | 6 +-- 4 files changed, 85 insertions(+), 10 deletions(-) rename pkg/{routes => models}/api_routes.go (78%) diff --git a/pkg/routes/api_routes.go b/pkg/models/api_routes.go similarity index 78% rename from pkg/routes/api_routes.go rename to pkg/models/api_routes.go index d25e68480..560564d7c 100644 --- a/pkg/routes/api_routes.go +++ b/pkg/models/api_routes.go @@ -14,14 +14,12 @@ // You should have received a copy of the GNU Affero General Public Licensee // along with this program. If not, see . -package routes +package models import ( "net/http" "strings" - "code.vikunja.io/api/pkg/models" - "github.com/labstack/echo/v4" ) @@ -64,8 +62,8 @@ func getRouteGroupName(path string) string { } } -// 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) { +// 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 @@ -130,7 +128,7 @@ 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 *models.APIToken) (can bool) { +func CanDoAPIRoute(c echo.Context, token *APIToken) (can bool) { routeGroupName := getRouteGroupName(c.Path()) group, hasGroup := token.Permissions[routeGroupName] @@ -168,3 +166,50 @@ func CanDoAPIRoute(c echo.Context, token *models.APIToken) (can bool) { 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 index 2794daa03..f31ea4ff5 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -102,7 +102,9 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { t.OwnerID = a.GetID() - // TODO: validate permissions + if err := PermissionsAreValid(t.Permissions); err != nil { + return err + } _, err = s.Insert(t) return err diff --git a/pkg/models/error.go b/pkg/models/error.go index 6bc62d58d..73866ad7a 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1685,3 +1685,31 @@ func (err ErrAPITokenInvalid) HTTPError() web.HTTPError { 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/routes/routes.go b/pkg/routes/routes.go index de54cf42a..04ad65822 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -204,7 +204,7 @@ 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) { - collectRoutesForAPITokenUsage(route) + models.CollectRoutesForAPITokenUsage(route) } registerAPIRoutes(a) } @@ -316,7 +316,7 @@ func registerAPIRoutes(a *echo.Group) { return echo.NewHTTPError(http.StatusUnauthorized) } - if !CanDoAPIRoute(c, token) { + if !models.CanDoAPIRoute(c, token) { return echo.NewHTTPError(http.StatusUnauthorized) } @@ -333,7 +333,7 @@ func registerAPIRoutes(a *echo.Group) { setupMetricsMiddleware(a) a.POST("/tokenTest", apiv1.CheckToken) - a.GET("/routes", GetAvailableAPIRoutesForToken) + a.GET("/routes", models.GetAvailableAPIRoutesForToken) // User stuff u := a.Group("/user") From d9bfcdab8e20074cbf752fa98537619892059926 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 10:19:31 +0200 Subject: [PATCH 10/17] feat(api tokens): add tests --- pkg/db/fixtures/api_tokens.yml | 33 ++++++++ pkg/integrations/api_tokens_test.go | 113 +++++++++++++++++++++++++++ pkg/models/api_routes.go | 13 ++-- pkg/models/api_tokens.go | 16 ++-- pkg/models/api_tokens_test.go | 117 ++++++++++++++++++++++++++++ pkg/models/models.go | 1 + pkg/models/unit_tests.go | 1 + 7 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 pkg/db/fixtures/api_tokens.yml create mode 100644 pkg/integrations/api_tokens_test.go create mode 100644 pkg/models/api_tokens_test.go diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml new file mode 100644 index 000000000..1a0f38b4a --- /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 000000000..4ccde721f --- /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 560564d7c..4336a8b8f 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 f31ea4ff5..b1a4c98ef 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 000000000..9b7bc10ae --- /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 4e212bdee..ec2e92f7c 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 fb383ed91..6dd66990b 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) From e295d75e6e4f0d2ea1d34dd82bf28cce0d70f000 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 10:19:55 +0200 Subject: [PATCH 11/17] feat(api tokens): move token validation middleware to new function --- pkg/routes/api_tokens.go | 77 ++++++++++++++++++++++++++++++++++++++++ pkg/routes/routes.go | 47 +----------------------- 2 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 pkg/routes/api_tokens.go diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go new file mode 100644 index 000000000..d272ea40a --- /dev/null +++ b/pkg/routes/api_tokens.go @@ -0,0 +1,77 @@ +// 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/log" + "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) + if err != nil { + log.Errorf("Could not check api token: %v", err) + return false + } + return true + } + } + + return false + }, + }) +} + +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 04ad65822..544096c13 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -51,7 +51,6 @@ package routes import ( "errors" - "net/http" "net/url" "strings" "time" @@ -81,7 +80,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" @@ -281,50 +279,7 @@ func registerAPIRoutes(a *echo.Group) { } // ===== Routes with Authentication ===== - a.Use(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) { - c.Set("api_token", s) - return true - } - } - - return false - }, - })) - - a.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - // If this is empty we assume we're dealing with a "real" user who has provided a jwt - tokenString, is := c.Get("api_token").(string) - if !is || tokenString == "" { - return next(c) - } - token, err := models.GetTokenFromTokenString(db.NewSession(), strings.TrimPrefix(tokenString, "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 next(c) - } - }) + a.Use(SetupTokenMiddleware()) // Rate limit setupRateLimit(a, config.RateLimitKind.GetString()) From 8f3d18a809ea2edd109309063d4b79410e864fb0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 10:27:56 +0200 Subject: [PATCH 12/17] feat(api tokens): better error message for invalid tokens --- pkg/routes/api_tokens.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index d272ea40a..2d5593e68 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -23,7 +23,6 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" echojwt "github.com/labstack/echo-jwt/v4" @@ -43,7 +42,6 @@ func SetupTokenMiddleware() echo.MiddlewareFunc { if strings.HasPrefix(s, "Bearer "+models.APITokenPrefix) { err := checkAPITokenAndPutItInContext(s, c) if err != nil { - log.Errorf("Could not check api token: %v", err) return false } return true @@ -52,6 +50,13 @@ func SetupTokenMiddleware() echo.MiddlewareFunc { 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 + }, }) } From e3c5a93f4f6ff6000a66595be24b82b5a0e9d436 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 11:16:36 +0200 Subject: [PATCH 13/17] chore(api tokens): remove updated date from tokens as it can't be updated anyway --- pkg/db/fixtures/api_tokens.yml | 3 --- pkg/migration/20230831155832.go | 3 +-- pkg/models/api_routes.go | 2 +- pkg/models/api_tokens.go | 2 -- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml index 1a0f38b4a..fae073256 100644 --- a/pkg/db/fixtures/api_tokens.yml +++ b/pkg/db/fixtures/api_tokens.yml @@ -7,7 +7,6 @@ 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' @@ -18,7 +17,6 @@ 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' @@ -29,5 +27,4 @@ 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/migration/20230831155832.go b/pkg/migration/20230831155832.go index 0eb616345..19157c767 100644 --- a/pkg/migration/20230831155832.go +++ b/pkg/migration/20230831155832.go @@ -31,9 +31,8 @@ type apiTokens20230831155832 struct { 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"` - Created time.Time `xorm:"created not null" json:"created"` - Updated time.Time `xorm:"updated not null" json:"updated"` OwnerID int64 `xorm:"bigint not null" json:"-"` + Created time.Time `xorm:"created not null" json:"created"` } func (apiTokens20230831155832) TableName() string { diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index 4336a8b8f..fba08dcd9 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -121,7 +121,7 @@ func CollectRoutesForAPITokenUsage(route echo.Route) { // @tags api // @Produce json // @Security JWTKeyAuth -// @Success 200 {array} routes.APITokenRoute "The list of all routes." +// @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) diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index b1a4c98ef..c3952b7b7 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -51,8 +51,6 @@ type APIToken struct { // A timestamp when this api key was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` - // A timestamp when this api key was last updated. You cannot change this value. - Updated time.Time `xorm:"updated not null" json:"updated"` OwnerID int64 `xorm:"bigint not null" json:"-"` From 14c5a8ca5b4813b05f9b689932c42755126fe067 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 12:58:29 +0200 Subject: [PATCH 14/17] fix(api tokens): make sure read one routes show up in routes endpoint --- pkg/models/api_routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index fba08dcd9..c7655a063 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -89,7 +89,7 @@ func CollectRoutesForAPITokenUsage(route echo.Route) { Method: route.Method, } } - if strings.Contains(route.Name, "ReadWeb") { + if strings.Contains(route.Name, "ReadOneWeb") { apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ Path: route.Path, Method: route.Method, From 974e1878f8bfc59afd0730a62afb3065bba20eb7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 15:53:27 +0200 Subject: [PATCH 15/17] chore(api tokens): add swagger docs about api token auth --- pkg/routes/routes.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 544096c13..b618ab3b8 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 From a4d946b4a9077b663fbdc1db4b157511d2beafc1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 15:54:39 +0200 Subject: [PATCH 16/17] fix(api tokens): test --- pkg/models/api_tokens_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/models/api_tokens_test.go b/pkg/models/api_tokens_test.go index 9b7bc10ae..ad5ee119f 100644 --- a/pkg/models/api_tokens_test.go +++ b/pkg/models/api_tokens_test.go @@ -90,6 +90,7 @@ func TestAPIToken_Create(t *testing.T) { db.LoadAndAssertFixtures(t) err := token.Create(s, u) + assert.NoError(t, err) }) } From eac4e455fd4b9077c3e216d4fe59b15d41570c8d Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 15:56:35 +0200 Subject: [PATCH 17/17] fix(api tokens): lint --- pkg/models/api_tokens.go | 1 + pkg/routes/api_tokens.go | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index c3952b7b7..637da13ca 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" diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index 2d5593e68..93c9a39a4 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -41,10 +41,7 @@ func SetupTokenMiddleware() echo.MiddlewareFunc { for _, s := range authHeader { if strings.HasPrefix(s, "Bearer "+models.APITokenPrefix) { err := checkAPITokenAndPutItInContext(s, c) - if err != nil { - return false - } - return true + return err == nil } }