feat: api tokens

Reviewed-on: vikunja/api#1600
This commit is contained in:
konrad 2023-09-01 14:34:39 +00:00
commit 60cd1250a0
17 changed files with 1008 additions and 52 deletions

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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))
})
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

216
pkg/models/api_routes.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

191
pkg/models/api_tokens.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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{}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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))
})
}

View File

@ -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),
}
}

View File

@ -58,6 +58,7 @@ func GetTables() []interface{} {
&SavedFilter{},
&Subscription{},
&Favorite{},
&APIToken{},
}
}

View File

@ -64,6 +64,7 @@ func SetupTests() {
"saved_filters",
"subscriptions",
"favorites",
"api_tokens",
)
if err != nil {
log.Fatal(err)

View File

@ -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() {

View File

@ -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"
)

79
pkg/routes/api_tokens.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -30,6 +30,8 @@
// @description # Authorization
// @description **JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer <jwt-token>`-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 <token>` 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 <!-- ReDoc-Inject: <security-definitions> -->
// @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) {

72
pkg/utils/random.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
package utils
import (
"math/rand"
"time"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
// MakeRandomString return a random string
func MakeRandomString(n int) string {
source := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, n)
// A rand.Int63() generates 63 random bits, enough for letterIdxMax letters!
for i, cache, remain := n-1, source.Int63(), letterIdxMax; i >= 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)
}